@frase/mcp-server 0.3.0 → 0.3.2

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 (68) hide show
  1. package/dist/formatter.d.ts +1 -1
  2. package/dist/formatter.d.ts.map +1 -1
  3. package/dist/formatter.js +1 -1
  4. package/dist/formatter.js.map +1 -1
  5. package/dist/lib/mcp-audit.d.ts +3 -0
  6. package/dist/lib/mcp-audit.d.ts.map +1 -0
  7. package/dist/lib/mcp-audit.js +95 -0
  8. package/dist/lib/mcp-audit.js.map +1 -0
  9. package/dist/mcpb-bundle.js +30072 -0
  10. package/dist/prompts/content-pipeline.d.ts.map +1 -1
  11. package/dist/prompts/content-pipeline.js +10 -18
  12. package/dist/prompts/content-pipeline.js.map +1 -1
  13. package/dist/prompts/create-seo-article.d.ts.map +1 -1
  14. package/dist/prompts/create-seo-article.js +17 -42
  15. package/dist/prompts/create-seo-article.js.map +1 -1
  16. package/dist/prompts/keyword-research.d.ts.map +1 -1
  17. package/dist/prompts/keyword-research.js +20 -31
  18. package/dist/prompts/keyword-research.js.map +1 -1
  19. package/dist/resources/briefs.d.ts.map +1 -1
  20. package/dist/resources/briefs.js +12 -6
  21. package/dist/resources/briefs.js.map +1 -1
  22. package/dist/tools/ai-visibility.d.ts.map +1 -1
  23. package/dist/tools/ai-visibility.js +67 -12
  24. package/dist/tools/ai-visibility.js.map +1 -1
  25. package/dist/tools/analytics.d.ts.map +1 -1
  26. package/dist/tools/analytics.js +9 -4
  27. package/dist/tools/analytics.js.map +1 -1
  28. package/dist/tools/audits.d.ts +8 -5
  29. package/dist/tools/audits.d.ts.map +1 -1
  30. package/dist/tools/audits.js +112 -20
  31. package/dist/tools/audits.js.map +1 -1
  32. package/dist/tools/briefs.d.ts +40 -145
  33. package/dist/tools/briefs.d.ts.map +1 -1
  34. package/dist/tools/briefs.js +153 -163
  35. package/dist/tools/briefs.js.map +1 -1
  36. package/dist/tools/content.d.ts +34 -6
  37. package/dist/tools/content.d.ts.map +1 -1
  38. package/dist/tools/content.js +351 -30
  39. package/dist/tools/content.js.map +1 -1
  40. package/dist/tools/discover.js +3 -3
  41. package/dist/tools/discover.js.map +1 -1
  42. package/dist/tools/optimizations.d.ts +4 -0
  43. package/dist/tools/optimizations.d.ts.map +1 -1
  44. package/dist/tools/optimizations.js +359 -26
  45. package/dist/tools/optimizations.js.map +1 -1
  46. package/dist/tools/publish.d.ts +3 -3
  47. package/dist/tools/publish.js +6 -6
  48. package/dist/tools/publish.js.map +1 -1
  49. package/dist/tools/research.d.ts +18 -6
  50. package/dist/tools/research.d.ts.map +1 -1
  51. package/dist/tools/research.js +210 -19
  52. package/dist/tools/research.js.map +1 -1
  53. package/dist/tools/rules.d.ts +76 -0
  54. package/dist/tools/rules.d.ts.map +1 -1
  55. package/dist/tools/rules.js +355 -12
  56. package/dist/tools/rules.js.map +1 -1
  57. package/dist/tools/serp.d.ts.map +1 -1
  58. package/dist/tools/serp.js +30 -18
  59. package/dist/tools/serp.js.map +1 -1
  60. package/dist/tools/site-health.js +10 -10
  61. package/dist/tools/site-health.js.map +1 -1
  62. package/dist/tools/templates.js +5 -5
  63. package/dist/tools/templates.js.map +1 -1
  64. package/dist/tools/webhooks.d.ts.map +1 -1
  65. package/dist/tools/webhooks.js +24 -3
  66. package/dist/tools/webhooks.js.map +1 -1
  67. package/package.json +8 -11
  68. package/server.json +4 -4
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Content tools
3
3
  */
4
+ /**
5
+ * Detect whether body content is HTML (vs plain markdown).
6
+ * Checks for common HTML block-level tags that indicate HTML authoring.
7
+ */
8
+ function isHtmlContent(text) {
9
+ return /<(h[1-6]|p|div|ul|ol|table|section|article|blockquote)\b/i.test(text);
10
+ }
4
11
  /**
5
12
  * Convert HTML body to readable markdown-like text.
6
13
  * Preserves headings, paragraphs, lists, and links while stripping raw HTML.
@@ -9,7 +16,7 @@
9
16
  function htmlToReadableText(html) {
10
17
  let text = html;
11
18
  // Convert headings to markdown
12
- text = text.replace(/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi, (_m, level, content) => {
19
+ text = text.replace(/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gis, (_m, level, content) => {
13
20
  return "\n" + "#".repeat(parseInt(level)) + " " + content.trim() + "\n";
14
21
  });
15
22
  // Convert <br> to newlines
@@ -21,15 +28,27 @@ function htmlToReadableText(html) {
21
28
  text = text.replace(/<li[^>]*>/gi, "- ");
22
29
  text = text.replace(/<\/li>/gi, "\n");
23
30
  // Convert <a> to markdown links
24
- text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
31
+ text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)");
25
32
  // Convert bold/italic
26
- text = text.replace(/<(strong|b)[^>]*>(.*?)<\/(strong|b)>/gi, "**$2**");
27
- text = text.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/gi, "*$2*");
33
+ text = text.replace(/<(strong|b)[^>]*>(.*?)<\/(strong|b)>/gis, "**$2**");
34
+ text = text.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/gis, "*$2*");
28
35
  // Convert blockquotes
29
36
  text = text.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, "> $1\n");
37
+ // Convert tables (PRO-1866)
38
+ text = text.replace(/<\/tr>/gi, "\n");
39
+ text = text.replace(/<tr[^>]*>/gi, "");
40
+ text = text.replace(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi, "$1\t");
41
+ text = text.replace(/<\/?(?:table|thead|tbody|tfoot|colgroup|col|caption)[^>]*>/gi, "\n");
42
+ // Convert code blocks (PRO-1866)
43
+ text = text.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, "\n```\n$1\n```\n");
44
+ text = text.replace(/<code[^>]*>(.*?)<\/code>/gis, "`$1`");
45
+ // Convert figure captions (PRO-1866)
46
+ text = text.replace(/<figcaption[^>]*>(.*?)<\/figcaption>/gis, "_$1_\n");
30
47
  // Add spacing around div/section blocks to prevent text running together
31
48
  text = text.replace(/<\/(div|section|article|aside|header|footer|figure|figcaption)>/gi, "\n");
32
49
  text = text.replace(/<(div|section|article|aside|header|footer|figure|figcaption)[^>]*>/gi, "\n");
50
+ // Preserve angle-bracket URLs before stripping remaining tags (PRO-1866)
51
+ text = text.replace(/<(https?:\/\/[^>]+)>/g, '$1');
33
52
  // Strip remaining HTML tags
34
53
  text = text.replace(/<[^>]+>/g, "");
35
54
  // Decode common HTML entities
@@ -48,15 +67,24 @@ import { formatTable, formatStatus, formatDate, formatPagination, formatKeyValue
48
67
  // ============================================
49
68
  // list_content
50
69
  // ============================================
70
+ // Display status values matching the Frase web app
71
+ const CONTENT_DISPLAY_STATUS_VALUES = ["drafting", "review", "published", "archived"];
72
+ // Map display status → database content status(es)
73
+ const CONTENT_STATUS_DISPLAY_TO_DB = {
74
+ drafting: ["draft", "generating"],
75
+ review: ["review"],
76
+ published: ["published"],
77
+ archived: ["archived"],
78
+ };
51
79
  const listContentInputSchema = z.object({
52
80
  page: z.number().min(1).optional().default(1),
53
81
  page_size: z.number().min(1).max(100).optional().default(20),
54
- status: z.enum(["draft", "generating", "review", "published", "archived"]).optional(),
82
+ status: z.enum(CONTENT_DISPLAY_STATUS_VALUES).optional(),
55
83
  site_id: z.string().optional(),
56
84
  });
57
85
  export const listContentTool = {
58
86
  name: "list_content",
59
- description: "List content items in your account. Content can be drafts, in review, published, or archived.",
87
+ description: "List content items in your account. Content can be drafts, in review, published, or archived. Use status 'archived' to list trashed/deleted content.",
60
88
  inputSchema: {
61
89
  type: "object",
62
90
  properties: {
@@ -70,8 +98,8 @@ export const listContentTool = {
70
98
  },
71
99
  status: {
72
100
  type: "string",
73
- enum: ["draft", "generating", "review", "published", "archived"],
74
- description: "Filter by status",
101
+ enum: ["drafting", "review", "published", "archived"],
102
+ description: "Filter by status. Values match the Frase app: drafting (content being written), review (content under review), published (live content), archived (trashed/deleted items).",
75
103
  },
76
104
  site_id: {
77
105
  type: "string",
@@ -90,17 +118,36 @@ export async function executeListContent(input, context) {
90
118
  }
91
119
  const { page, page_size, status, site_id } = parsed.data;
92
120
  try {
121
+ // Map display status to DB statuses for the API query
122
+ const dbStatuses = status ? CONTENT_STATUS_DISPLAY_TO_DB[status] : undefined;
123
+ // If the display status maps to a single DB status, pass it directly;
124
+ // otherwise fetch all and filter client-side
125
+ const singleDbStatus = dbStatuses?.length === 1 ? dbStatuses[0] : undefined;
93
126
  const params = { page, page_size };
94
- if (status)
95
- params.status = status;
127
+ if (singleDbStatus)
128
+ params.status = singleDbStatus;
96
129
  if (site_id)
97
130
  params.site_id = site_id;
98
- const response = await context.client.get("/content", params, CACHE_TTL.lists);
131
+ // Use TTL 0 for archived content to ensure freshness after recent deletes
132
+ const cacheTtl = status === "archived" ? 0 : CACHE_TTL.lists;
133
+ const response = await context.client.get("/content", params, cacheTtl);
134
+ // Client-side filter when display status maps to multiple DB statuses
135
+ const filteredData = dbStatuses && !singleDbStatus
136
+ ? response.data.filter((c) => dbStatuses.includes(c.status))
137
+ : response.data;
138
+ // Map DB status → display label matching the Frase web app
139
+ const DB_STATUS_TO_LABEL = {
140
+ draft: "Drafting",
141
+ generating: "Drafting",
142
+ review: "In Review",
143
+ published: "Published",
144
+ archived: "Archived",
145
+ };
99
146
  const headers = ["ID", "Title", "Status", "Words", "Updated"];
100
- const rows = response.data.map((c) => [
147
+ const rows = filteredData.map((c) => [
101
148
  c.id,
102
149
  truncate(c.title || "Untitled", 40),
103
- formatStatus(c.status),
150
+ DB_STATUS_TO_LABEL[c.status] || formatStatus(c.status),
104
151
  formatWordCount(c.word_count),
105
152
  c.updated_at ? formatDate(c.updated_at) : formatDate(c.created_at),
106
153
  ]);
@@ -155,12 +202,21 @@ export async function executeGetContent(input, context) {
155
202
  }
156
203
  const { id, include_body } = parsed.data;
157
204
  try {
158
- const response = await context.client.get(`/content/${id}`, undefined, CACHE_TTL.resources);
205
+ const response = await context.client.get(`/content/${id}`, undefined, 0 // Never cache content — body changes after regeneration/improvement
206
+ );
159
207
  const content = response.data;
208
+ // Map DB status → display label matching the Frase web app
209
+ const DETAIL_STATUS_LABEL = {
210
+ draft: "Drafting",
211
+ generating: "Drafting",
212
+ review: "In Review",
213
+ published: "Published",
214
+ archived: "Archived",
215
+ };
160
216
  let markdown = `## ${content.title || "Untitled Content"}\n\n`;
161
217
  markdown += formatKeyValue({
162
218
  ID: content.id,
163
- Status: content.status,
219
+ Status: DETAIL_STATUS_LABEL[content.status] || content.status,
164
220
  "Word Count": content.word_count,
165
221
  Type: content.type,
166
222
  Slug: content.slug,
@@ -172,6 +228,34 @@ export async function executeGetContent(input, context) {
172
228
  Updated: content.updated_at ? formatDate(content.updated_at) : null,
173
229
  Published: content.published_at ? formatDate(content.published_at) : null,
174
230
  });
231
+ // E-E-A-T Breakdown
232
+ if (content.eeat_breakdown) {
233
+ const b = content.eeat_breakdown;
234
+ markdown += `\n\n### E-E-A-T Breakdown\n\n`;
235
+ markdown += `| Pillar | Score | Details |\n|--------|-------|--------|\n`;
236
+ const exp = b.experience;
237
+ const isV2Experience = exp?.firstPersonAction !== undefined;
238
+ if (isV2Experience) {
239
+ markdown += `| Experience | ${exp?.score ?? 0}/20 | Action: ${exp?.firstPersonAction ?? 0}/4, Specifics: ${exp?.specificity ?? 0}/4, Duration: ${exp?.temporalDepth ?? 0}/3, Artifacts: ${exp?.originalArtifacts ?? 0}/4, Outcomes: ${exp?.outcomeLanguage ?? 0}/3, Declaration: ${exp?.experienceDeclaration ?? 0}/2 |\n`;
240
+ }
241
+ else {
242
+ markdown += `| Experience | ${exp?.score ?? 0}/20 | First-person: ${exp?.firstPersonUsage ?? 0}/8, Media: ${exp?.originalMedia ?? 0}/6, Narrative: ${exp?.narrativeEvidence ?? 0}/6 |\n`;
243
+ }
244
+ markdown += `| Expertise | ${b.expertise?.score ?? 0}/20 | Author entity: ${b.expertise?.authorEntity ?? 0}/6, Bio quality: ${b.expertise?.bioQuality ?? 0}/8, Depth: ${b.expertise?.topicalDepth ?? 0}/6 |\n`;
245
+ markdown += `| Authority | ${b.authority?.score ?? 0}/20 | Expertise signals: ${b.authority?.expertiseSignals ?? 0}/8, Social proof: ${b.authority?.socialProof ?? 0}/12${b.authority?.brandMentions > 0 ? `, Brand mentions: ${b.authority.brandMentions}` : ""} |\n`;
246
+ markdown += `| Trust | ${b.trust?.score ?? 0}/40 | Source Authority: ${b.trust?.citationVelocity ?? 0}/12, Policies: ${b.trust?.policyTransparency ?? 0}/10, Freshness: ${b.trust?.freshness ?? 0}/10, Disclosure: ${b.trust?.conflictOfInterest ?? 0}/8 |\n`;
247
+ // Weight annotation for non-default content-type weights (PRO-2169 3E)
248
+ const w = b.appliedWeights;
249
+ const isDefault = !w || (w.trust === 47 && w.experience === 0 && w.expertise === 33 && w.authority === 20);
250
+ if (!isDefault && w) {
251
+ const label = getWeightLabel(b.appliedWeightProfile || "custom");
252
+ markdown += `| Applied Weights | | Trust ${w.trust ?? 0}% · Experience ${w.experience ?? 0}% · Expertise ${w.expertise ?? 0}% · Authority ${w.authority ?? 0}% |\n`;
253
+ markdown += `\n> Weights adjusted for ${label} content.\n`;
254
+ }
255
+ if (b.isYMYL) {
256
+ markdown += `\n> **YMYL content detected** — higher quality standards apply for this topic.\n`;
257
+ }
258
+ }
175
259
  // Meta tags
176
260
  if (content.meta_title || content.meta_description) {
177
261
  markdown += `\n\n### Meta Tags\n\n`;
@@ -182,10 +266,25 @@ export async function executeGetContent(input, context) {
182
266
  markdown += `- **Description:** ${content.meta_description}\n`;
183
267
  }
184
268
  }
185
- // Content body convert HTML to readable text to prevent content loss
269
+ // PRO-1887: Surface improvement status so Claude knows whether an async improvement is done
270
+ if (content.progress_details?.improvingStatus) {
271
+ const status = content.progress_details.improvingStatus;
272
+ if (status === "in_progress") {
273
+ markdown += `\n\n> **Improvement status:** in_progress — An AI improvement is currently being applied. Wait 15-30 seconds and call \`get_content\` again.\n`;
274
+ }
275
+ else if (status === "completed") {
276
+ markdown += `\n\n> **Improvement status:** completed — The AI improvement was successfully applied.\n`;
277
+ }
278
+ else if (status === "failed") {
279
+ markdown += `\n\n> **Improvement status:** failed — The AI improvement failed. You may retry with \`regenerate_content\`.\n`;
280
+ }
281
+ }
282
+ // Content body — convert HTML to readable text; pass markdown through as-is (PRO-1866)
186
283
  if (include_body && content.body) {
187
284
  markdown += `\n\n### Content Body\n\n`;
188
- const readableBody = htmlToReadableText(content.body);
285
+ const readableBody = isHtmlContent(content.body)
286
+ ? htmlToReadableText(content.body)
287
+ : content.body;
189
288
  // Truncate extremely long content (50K chars ≈ 8K+ words)
190
289
  if (readableBody.length > 50000) {
191
290
  markdown += readableBody.slice(0, 50000);
@@ -244,6 +343,27 @@ export async function executeGenerateContent(input, context) {
244
343
  }
245
344
  const { brief_id, generate_images } = parsed.data;
246
345
  try {
346
+ // Check if the brief already has linked content (app hides Generate button in this case)
347
+ try {
348
+ const briefResponse = await context.client.get(`/briefs/${brief_id}`, undefined, 0);
349
+ if (briefResponse.data?.linked_content) {
350
+ const linked = briefResponse.data.linked_content;
351
+ return {
352
+ success: false,
353
+ markdown: `**Error:** This brief already has linked content.\n\n` +
354
+ formatKeyValue({
355
+ "Existing Content ID": linked.id,
356
+ Title: linked.title || "Untitled",
357
+ Status: formatStatus(linked.status),
358
+ }) +
359
+ `\n\nTo regenerate this content, use \`regenerate_content\` with \`content_id: "${linked.id}"\`.\n` +
360
+ `To improve it with specific instructions, use \`regenerate_content\` with \`content_id: "${linked.id}"\` and \`instructions\`.`,
361
+ };
362
+ }
363
+ }
364
+ catch {
365
+ // Brief lookup failed — proceed with generation anyway (API will handle validation)
366
+ }
247
367
  const response = await context.client.post("/content/generate", { brief_id, generate_images });
248
368
  const content = response.data;
249
369
  let markdown = `## Content Generation Started\n\n`;
@@ -255,7 +375,7 @@ export async function executeGenerateContent(input, context) {
255
375
  "Generate Images": generate_images ? "Yes" : "No",
256
376
  Created: formatDate(content.created_at),
257
377
  });
258
- markdown += `\n\n> **Tip:** Use \`get_job_status\` with the content ID to track generation progress.`;
378
+ markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track generation progress (~2-5 minutes).`;
259
379
  return {
260
380
  success: true,
261
381
  markdown,
@@ -273,17 +393,23 @@ export async function executeGenerateContent(input, context) {
273
393
  // ============================================
274
394
  // update_content
275
395
  // ============================================
396
+ // Map update_content display status → single DB status
397
+ const UPDATE_STATUS_DISPLAY_TO_DB = {
398
+ drafting: "draft",
399
+ review: "review",
400
+ published: "published",
401
+ };
276
402
  const updateContentInputSchema = z.object({
277
403
  id: z.string().min(1),
278
404
  title: z.string().optional(),
279
405
  body: z.string().optional(),
280
- status: z.enum(["draft", "generating", "review", "published", "archived"]).optional(),
406
+ status: z.enum(["drafting", "review", "published"]).optional(),
281
407
  meta_title: z.string().optional(),
282
408
  meta_description: z.string().optional(),
283
409
  });
284
410
  export const updateContentTool = {
285
411
  name: "update_content",
286
- description: "Update a content item's title, body, status, or meta tags.",
412
+ description: "Update a content item's title, body, status, or meta tags. To archive/trash content, use delete_content instead.",
287
413
  inputSchema: {
288
414
  type: "object",
289
415
  properties: {
@@ -301,8 +427,8 @@ export const updateContentTool = {
301
427
  },
302
428
  status: {
303
429
  type: "string",
304
- enum: ["draft", "generating", "review", "published", "archived"],
305
- description: "New status",
430
+ enum: ["drafting", "review", "published"],
431
+ description: "New status. Values match the Frase app: drafting (move to drafts), review (send for review), published (mark as published). To archive/trash content, use delete_content instead.",
306
432
  },
307
433
  meta_title: {
308
434
  type: "string",
@@ -324,9 +450,11 @@ export async function executeUpdateContent(input, context) {
324
450
  markdown: `**Error:** Invalid input - ${parsed.error.message}`,
325
451
  };
326
452
  }
327
- const { id, ...updates } = parsed.data;
328
- // Filter out undefined values
329
- const fieldsToUpdate = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined));
453
+ const { id, status: displayStatus, ...otherUpdates } = parsed.data;
454
+ // Map display status to DB status
455
+ const dbStatus = displayStatus ? UPDATE_STATUS_DISPLAY_TO_DB[displayStatus] : undefined;
456
+ // Filter out undefined values and include mapped DB status
457
+ const fieldsToUpdate = Object.fromEntries(Object.entries({ ...otherUpdates, status: dbStatus }).filter(([, v]) => v !== undefined));
330
458
  if (Object.keys(fieldsToUpdate).length === 0) {
331
459
  return {
332
460
  success: false,
@@ -398,7 +526,7 @@ export async function executeDeleteContent(input, context) {
398
526
  await context.client.delete(`/content/${id}`);
399
527
  return {
400
528
  success: true,
401
- markdown: `## Content Deleted\n\nContent \`${id}\` has been moved to trash.`,
529
+ markdown: `## Content Deleted\n\nContent \`${id}\` has been moved to trash.\n\n> **Tip:** To view trashed items, use \`list_content\` with \`status: "archived"\`.`,
402
530
  };
403
531
  }
404
532
  catch (error) {
@@ -439,7 +567,8 @@ export async function executeGetContentStatus(input, context) {
439
567
  }
440
568
  const { id } = parsed.data;
441
569
  try {
442
- const response = await context.client.get(`/content/${id}/status`, undefined, CACHE_TTL.resources);
570
+ const response = await context.client.get(`/content/${id}/status`, undefined, 0 // Never cache status — this is a polling endpoint (app polls fresh every 2s)
571
+ );
443
572
  const status = response.data;
444
573
  let markdown = `## Content Status\n\n`;
445
574
  markdown += formatKeyValue({
@@ -558,7 +687,7 @@ const regenerateContentInputSchema = z.object({
558
687
  });
559
688
  export const regenerateContentTool = {
560
689
  name: "regenerate_content",
561
- description: "Regenerate or improve existing content. Without instructions, the content is fully regenerated from its brief. With instructions, the content is rewritten using AI following those instructions. Both operations run asynchronously use get_content_status to track progress. Note: AI improvements may change the word count; include 'maintain the current word count' in instructions if length preservation is important.",
690
+ description: "Regenerate or improve existing content. This is an async operation. Without instructions, fully regenerates from brief (~2-5 min; poll get_content_status until status changes from 'generating' to 'review'). With instructions, rewrites using AI (~30-60 sec). After calling this tool with instructions, wait 30 seconds then use get_content to check: look for improvingStatus ('completed' = done, 'in_progress' = wait and retry, 'failed' = error) or compare the updatedAt timestamp. Note: AI improvements may change the word count; include 'maintain the current word count' in instructions if length preservation is important.",
562
691
  inputSchema: {
563
692
  type: "object",
564
693
  properties: {
@@ -601,11 +730,15 @@ export async function executeRegenerateContent(input, context) {
601
730
  Status: formatStatus(result.status),
602
731
  "Brief ID": result.brief_id,
603
732
  Method: instructions ? "AI Improvement" : "Full Regeneration",
733
+ "Current updatedAt": result.updated_at || "unknown",
604
734
  });
605
735
  if (instructions) {
606
736
  markdown += `\n\n**Instructions:** ${instructions.length > 200 ? instructions.slice(0, 200) + "..." : instructions}`;
737
+ markdown += `\n\n> **Important:** AI improvement is an async background operation (~30-60 seconds). Wait 30 seconds, then call \`get_content\` to check the result. Look for **improvingStatus** in the response: \`completed\` means the improvement was applied, \`in_progress\` means it is still running (wait and retry), \`failed\` means it errored. You can also compare the \`updatedAt\` timestamp — if it changed from \`${result.updated_at || "the value above"}\`, the content was updated.`;
738
+ }
739
+ else {
740
+ markdown += `\n\n> **Important:** Full regeneration is an async operation (~2-5 minutes). Use \`get_content_status\` to poll until status changes from "generating" to "review", then use \`get_content\` to read the new body text.`;
607
741
  }
608
- markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track regeneration progress.`;
609
742
  return {
610
743
  success: true,
611
744
  markdown,
@@ -655,10 +788,18 @@ export async function executeRescoreContent(input, context) {
655
788
  let markdown = `## Content Rescored\n\n`;
656
789
  // Score summary table
657
790
  const headers = ["Score Type", "Score", "Rating"];
791
+ // Check for non-default EEAT weights (PRO-2169 3E)
792
+ const eeatBd = result.scores.eeat_breakdown;
793
+ const aw = eeatBd?.appliedWeights;
794
+ const isDefaultWeights = !aw || (aw.trust === 47 && aw.experience === 0 && aw.expertise === 33 && aw.authority === 20);
795
+ const weightLabel = !isDefaultWeights
796
+ ? getWeightLabel(eeatBd?.appliedWeightProfile || "custom")
797
+ : null;
798
+ const eeatRating = getScoreRating(result.scores.eeat_score) + (weightLabel ? ` (weighted for ${weightLabel})` : "");
658
799
  const rows = [
659
800
  ["SEO", String(result.scores.seo_score), getScoreRating(result.scores.seo_score)],
660
801
  ["GEO", String(result.scores.geo_score), getScoreRating(result.scores.geo_score)],
661
- ["EEAT", String(result.scores.eeat_score), getScoreRating(result.scores.eeat_score)],
802
+ ["EEAT", String(result.scores.eeat_score), eeatRating],
662
803
  ];
663
804
  markdown += formatTable(headers, rows);
664
805
  markdown += `\n\n`;
@@ -681,6 +822,14 @@ export async function executeRescoreContent(input, context) {
681
822
  };
682
823
  }
683
824
  }
825
+ /** Convert weight profile key to human-readable label (shared by get_content + rescore_content) */
826
+ function getWeightLabel(profile) {
827
+ if (profile.startsWith("blog:"))
828
+ return profile.split(":")[1];
829
+ if (profile === "landing" || profile === "product")
830
+ return `${profile} page`;
831
+ return profile;
832
+ }
684
833
  function getScoreRating(score) {
685
834
  if (score >= 80)
686
835
  return "Excellent";
@@ -693,14 +842,183 @@ function getScoreRating(score) {
693
842
  return "Not Competitive";
694
843
  }
695
844
  // ============================================
845
+ // get_content_summary
846
+ // ============================================
847
+ export const getContentSummaryTool = {
848
+ name: "get_content_summary",
849
+ description: "Get a summary of content counts grouped by status. Returns accurate totals for draft, generating, review, published, and archived content. Use this instead of list_content when you need an overview of how much content exists per status.",
850
+ inputSchema: {
851
+ type: "object",
852
+ properties: {},
853
+ },
854
+ };
855
+ export async function executeGetContentSummary(_input, context) {
856
+ try {
857
+ const response = await context.client.get("/content/summary", undefined, CACHE_TTL.lists);
858
+ const { status_counts, total } = response.data;
859
+ // Build LLM-friendly text output
860
+ let markdown = `## Content Summary\n\n`;
861
+ markdown += `**Total:** ${total} content item${total === 1 ? "" : "s"}\n\n`;
862
+ if (status_counts.length === 0) {
863
+ markdown += `No content items found.\n`;
864
+ }
865
+ else {
866
+ // Sort by count descending for readability
867
+ const sorted = [...status_counts].sort((a, b) => b.count - a.count);
868
+ for (const { status, count } of sorted) {
869
+ markdown += `- **${formatStatus(status)}:** ${count}\n`;
870
+ }
871
+ }
872
+ return {
873
+ success: true,
874
+ markdown,
875
+ data: response,
876
+ };
877
+ }
878
+ catch (error) {
879
+ const message = error instanceof Error ? error.message : "Unknown error";
880
+ return {
881
+ success: false,
882
+ markdown: `**Error:** Failed to get content summary - ${message}`,
883
+ };
884
+ }
885
+ }
886
+ // ============================================
887
+ // restore_content
888
+ // ============================================
889
+ const restoreContentInputSchema = z.object({
890
+ id: z.string().min(1),
891
+ });
892
+ export const restoreContentTool = {
893
+ name: "restore_content",
894
+ description: "Restore a trashed (soft-deleted) content item. Content can be restored within 30 days of deletion. Use list_content with status 'archived' to find trashed items.",
895
+ inputSchema: {
896
+ type: "object",
897
+ properties: {
898
+ id: {
899
+ type: "string",
900
+ description: "The content ID to restore (required)",
901
+ },
902
+ },
903
+ required: ["id"],
904
+ },
905
+ };
906
+ export async function executeRestoreContent(input, context) {
907
+ const parsed = restoreContentInputSchema.safeParse(input);
908
+ if (!parsed.success) {
909
+ return {
910
+ success: false,
911
+ markdown: `**Error:** Invalid input - ${parsed.error.message}`,
912
+ };
913
+ }
914
+ const { id } = parsed.data;
915
+ try {
916
+ const response = await context.client.post(`/content/${id}/restore`, {});
917
+ const content = response.data;
918
+ return {
919
+ success: true,
920
+ markdown: `## Content Restored\n\n${formatKeyValue({
921
+ ID: content.id,
922
+ Title: content.title,
923
+ Status: formatStatus(content.status),
924
+ })}\n\nThe content has been restored from trash and is available again.`,
925
+ data: response,
926
+ };
927
+ }
928
+ catch (error) {
929
+ const message = error instanceof Error ? error.message : "Unknown error";
930
+ return {
931
+ success: false,
932
+ markdown: `**Error:** Failed to restore content - ${message}`,
933
+ };
934
+ }
935
+ }
936
+ // ============================================
937
+ // list_trash
938
+ // ============================================
939
+ const listTrashInputSchema = z.object({
940
+ limit: z.number().min(1).max(100).optional().default(20),
941
+ offset: z.number().min(0).optional().default(0),
942
+ site_id: z.string().optional(),
943
+ });
944
+ export const listTrashTool = {
945
+ name: "list_trash",
946
+ description: "List trashed/deleted content items. Returns content that has been soft-deleted (moved to trash).",
947
+ inputSchema: {
948
+ type: "object",
949
+ properties: {
950
+ limit: {
951
+ type: "number",
952
+ description: "Max items to return, max 100 (default: 20)",
953
+ },
954
+ offset: {
955
+ type: "number",
956
+ description: "Number of items to skip for pagination (default: 0)",
957
+ },
958
+ site_id: {
959
+ type: "string",
960
+ description: "Filter by site ID. Use list_sites to get available site IDs.",
961
+ },
962
+ },
963
+ },
964
+ };
965
+ export async function executeListTrash(input, context) {
966
+ const parsed = listTrashInputSchema.safeParse(input || {});
967
+ if (!parsed.success) {
968
+ return {
969
+ success: false,
970
+ markdown: `**Error:** Invalid input - ${parsed.error.message}`,
971
+ };
972
+ }
973
+ const { limit, offset, site_id } = parsed.data;
974
+ // Convert limit/offset to page/page_size for the API
975
+ const page = Math.floor(offset / limit) + 1;
976
+ try {
977
+ const params = {
978
+ page,
979
+ page_size: limit,
980
+ status: "archived",
981
+ };
982
+ if (site_id)
983
+ params.site_id = site_id;
984
+ const response = await context.client.get("/content", params, 0 // No cache — ensure freshness after recent deletes/restores
985
+ );
986
+ const headers = ["ID", "Title", "Status", "Words", "Updated"];
987
+ const rows = response.data.map((c) => [
988
+ c.id,
989
+ truncate(c.title || "Untitled", 40),
990
+ formatStatus(c.status),
991
+ formatWordCount(c.word_count),
992
+ c.updated_at ? formatDate(c.updated_at) : formatDate(c.created_at),
993
+ ]);
994
+ const table = formatTable(headers, rows);
995
+ const pagination = formatPagination(response.pagination);
996
+ return {
997
+ success: true,
998
+ markdown: `## Trash (Page ${page})\n\n${table}${pagination}\n\n> **Tip:** Use \`restore_content\` with a content ID to recover an item from trash.`,
999
+ data: response,
1000
+ };
1001
+ }
1002
+ catch (error) {
1003
+ const message = error instanceof Error ? error.message : "Unknown error";
1004
+ return {
1005
+ success: false,
1006
+ markdown: `**Error:** Failed to list trashed content - ${message}`,
1007
+ };
1008
+ }
1009
+ }
1010
+ // ============================================
696
1011
  // Export all content tools
697
1012
  // ============================================
698
1013
  export const contentTools = [
699
1014
  listContentTool,
1015
+ listTrashTool,
700
1016
  getContentTool,
1017
+ getContentSummaryTool,
701
1018
  generateContentTool,
702
1019
  updateContentTool,
703
1020
  deleteContentTool,
1021
+ restoreContentTool,
704
1022
  getContentStatusTool,
705
1023
  exportContentTool,
706
1024
  regenerateContentTool,
@@ -708,10 +1026,13 @@ export const contentTools = [
708
1026
  ];
709
1027
  export const contentExecutors = {
710
1028
  list_content: executeListContent,
1029
+ list_trash: executeListTrash,
711
1030
  get_content: executeGetContent,
1031
+ get_content_summary: executeGetContentSummary,
712
1032
  generate_content: executeGenerateContent,
713
1033
  update_content: executeUpdateContent,
714
1034
  delete_content: executeDeleteContent,
1035
+ restore_content: executeRestoreContent,
715
1036
  get_content_status: executeGetContentStatus,
716
1037
  export_content: executeExportContent,
717
1038
  regenerate_content: executeRegenerateContent,