@frase/mcp-server 0.3.0 → 0.3.3
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/dist/formatter.d.ts +1 -1
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +1 -1
- package/dist/formatter.js.map +1 -1
- package/dist/lib/mcp-audit.d.ts +3 -0
- package/dist/lib/mcp-audit.d.ts.map +1 -0
- package/dist/lib/mcp-audit.js +95 -0
- package/dist/lib/mcp-audit.js.map +1 -0
- package/dist/mcpb-bundle.js +30072 -0
- package/dist/prompts/content-pipeline.d.ts.map +1 -1
- package/dist/prompts/content-pipeline.js +10 -18
- package/dist/prompts/content-pipeline.js.map +1 -1
- package/dist/prompts/create-seo-article.d.ts.map +1 -1
- package/dist/prompts/create-seo-article.js +17 -42
- package/dist/prompts/create-seo-article.js.map +1 -1
- package/dist/prompts/keyword-research.d.ts.map +1 -1
- package/dist/prompts/keyword-research.js +20 -31
- package/dist/prompts/keyword-research.js.map +1 -1
- package/dist/resources/briefs.d.ts.map +1 -1
- package/dist/resources/briefs.js +12 -6
- package/dist/resources/briefs.js.map +1 -1
- package/dist/tools/ai-visibility.d.ts.map +1 -1
- package/dist/tools/ai-visibility.js +67 -12
- package/dist/tools/ai-visibility.js.map +1 -1
- package/dist/tools/analytics.d.ts.map +1 -1
- package/dist/tools/analytics.js +9 -4
- package/dist/tools/analytics.js.map +1 -1
- package/dist/tools/audits.d.ts +8 -5
- package/dist/tools/audits.d.ts.map +1 -1
- package/dist/tools/audits.js +112 -20
- package/dist/tools/audits.js.map +1 -1
- package/dist/tools/briefs.d.ts +40 -145
- package/dist/tools/briefs.d.ts.map +1 -1
- package/dist/tools/briefs.js +153 -163
- package/dist/tools/briefs.js.map +1 -1
- package/dist/tools/cms-posts.d.ts +7 -0
- package/dist/tools/cms-posts.d.ts.map +1 -0
- package/dist/tools/cms-posts.js +499 -0
- package/dist/tools/cms-posts.js.map +1 -0
- package/dist/tools/content.d.ts +37 -6
- package/dist/tools/content.d.ts.map +1 -1
- package/dist/tools/content.js +362 -32
- package/dist/tools/content.js.map +1 -1
- package/dist/tools/discover.js +3 -3
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +5 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/optimizations.d.ts +6 -0
- package/dist/tools/optimizations.d.ts.map +1 -1
- package/dist/tools/optimizations.js +440 -26
- package/dist/tools/optimizations.js.map +1 -1
- package/dist/tools/publish.d.ts +3 -3
- package/dist/tools/publish.js +6 -6
- package/dist/tools/publish.js.map +1 -1
- package/dist/tools/research.d.ts +18 -6
- package/dist/tools/research.d.ts.map +1 -1
- package/dist/tools/research.js +210 -19
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/rules.d.ts +76 -0
- package/dist/tools/rules.d.ts.map +1 -1
- package/dist/tools/rules.js +355 -12
- package/dist/tools/rules.js.map +1 -1
- package/dist/tools/serp.d.ts.map +1 -1
- package/dist/tools/serp.js +30 -18
- package/dist/tools/serp.js.map +1 -1
- package/dist/tools/site-health.js +10 -10
- package/dist/tools/site-health.js.map +1 -1
- package/dist/tools/templates.js +5 -5
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/webhooks.d.ts.map +1 -1
- package/dist/tools/webhooks.js +24 -3
- package/dist/tools/webhooks.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -11
- package/server.json +4 -4
package/dist/tools/content.js
CHANGED
|
@@ -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]>/
|
|
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>/
|
|
31
|
+
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)");
|
|
25
32
|
// Convert bold/italic
|
|
26
|
-
text = text.replace(/<(strong|b)[^>]*>(.*?)<\/(strong|b)>/
|
|
27
|
-
text = text.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/
|
|
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,26 @@ 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(
|
|
82
|
+
status: z.enum(CONTENT_DISPLAY_STATUS_VALUES).optional(),
|
|
55
83
|
site_id: z.string().optional(),
|
|
84
|
+
// Content Guard Phase 4 (PRO-2177) — filter to articles under Content Guard monitoring.
|
|
85
|
+
is_monitored: z.boolean().optional(),
|
|
56
86
|
});
|
|
57
87
|
export const listContentTool = {
|
|
58
88
|
name: "list_content",
|
|
59
|
-
description: "List content items in your account. Content can be drafts, in review, published, or archived.",
|
|
89
|
+
description: "List content items in your account. Content can be drafts, in review, published, or archived. Use status 'archived' to list trashed/deleted content. Pass is_monitored=true to filter to articles under Content Guard monitoring (guarded), or is_monitored=false to exclude them.",
|
|
60
90
|
inputSchema: {
|
|
61
91
|
type: "object",
|
|
62
92
|
properties: {
|
|
@@ -70,13 +100,17 @@ export const listContentTool = {
|
|
|
70
100
|
},
|
|
71
101
|
status: {
|
|
72
102
|
type: "string",
|
|
73
|
-
enum: ["
|
|
74
|
-
description: "Filter by status",
|
|
103
|
+
enum: ["drafting", "review", "published", "archived"],
|
|
104
|
+
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
105
|
},
|
|
76
106
|
site_id: {
|
|
77
107
|
type: "string",
|
|
78
108
|
description: "Filter by site ID. Use list_sites to get available site IDs.",
|
|
79
109
|
},
|
|
110
|
+
is_monitored: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
description: "Filter to articles currently under Content Guard monitoring (true) or exclude them (false). Omit to return all content.",
|
|
113
|
+
},
|
|
80
114
|
},
|
|
81
115
|
},
|
|
82
116
|
};
|
|
@@ -88,20 +122,42 @@ export async function executeListContent(input, context) {
|
|
|
88
122
|
markdown: `**Error:** Invalid input - ${parsed.error.message}`,
|
|
89
123
|
};
|
|
90
124
|
}
|
|
91
|
-
const { page, page_size, status, site_id } = parsed.data;
|
|
125
|
+
const { page, page_size, status, site_id, is_monitored } = parsed.data;
|
|
92
126
|
try {
|
|
127
|
+
// Map display status to DB statuses for the API query
|
|
128
|
+
const dbStatuses = status ? CONTENT_STATUS_DISPLAY_TO_DB[status] : undefined;
|
|
129
|
+
// If the display status maps to a single DB status, pass it directly;
|
|
130
|
+
// otherwise fetch all and filter client-side
|
|
131
|
+
const singleDbStatus = dbStatuses?.length === 1 ? dbStatuses[0] : undefined;
|
|
93
132
|
const params = { page, page_size };
|
|
94
|
-
if (
|
|
95
|
-
params.status =
|
|
133
|
+
if (singleDbStatus)
|
|
134
|
+
params.status = singleDbStatus;
|
|
96
135
|
if (site_id)
|
|
97
136
|
params.site_id = site_id;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
137
|
+
if (is_monitored !== undefined)
|
|
138
|
+
params.is_monitored = String(is_monitored);
|
|
139
|
+
// Use TTL 0 for archived content to ensure freshness after recent deletes
|
|
140
|
+
const cacheTtl = status === "archived" ? 0 : CACHE_TTL.lists;
|
|
141
|
+
const response = await context.client.get("/content", params, cacheTtl);
|
|
142
|
+
// Client-side filter when display status maps to multiple DB statuses
|
|
143
|
+
const filteredData = dbStatuses && !singleDbStatus
|
|
144
|
+
? response.data.filter((c) => dbStatuses.includes(c.status))
|
|
145
|
+
: response.data;
|
|
146
|
+
// Map DB status → display label matching the Frase web app
|
|
147
|
+
const DB_STATUS_TO_LABEL = {
|
|
148
|
+
draft: "Drafting",
|
|
149
|
+
generating: "Drafting",
|
|
150
|
+
review: "In Review",
|
|
151
|
+
published: "Published",
|
|
152
|
+
archived: "Archived",
|
|
153
|
+
};
|
|
154
|
+
const headers = ["ID", "Title", "Status", "Words", "Guarded", "Updated"];
|
|
155
|
+
const rows = filteredData.map((c) => [
|
|
101
156
|
c.id,
|
|
102
157
|
truncate(c.title || "Untitled", 40),
|
|
103
|
-
formatStatus(c.status),
|
|
158
|
+
DB_STATUS_TO_LABEL[c.status] || formatStatus(c.status),
|
|
104
159
|
formatWordCount(c.word_count),
|
|
160
|
+
c.is_monitored === true ? "\u2713" : "",
|
|
105
161
|
c.updated_at ? formatDate(c.updated_at) : formatDate(c.created_at),
|
|
106
162
|
]);
|
|
107
163
|
const table = formatTable(headers, rows);
|
|
@@ -155,12 +211,21 @@ export async function executeGetContent(input, context) {
|
|
|
155
211
|
}
|
|
156
212
|
const { id, include_body } = parsed.data;
|
|
157
213
|
try {
|
|
158
|
-
const response = await context.client.get(`/content/${id}`, undefined,
|
|
214
|
+
const response = await context.client.get(`/content/${id}`, undefined, 0 // Never cache content — body changes after regeneration/improvement
|
|
215
|
+
);
|
|
159
216
|
const content = response.data;
|
|
217
|
+
// Map DB status → display label matching the Frase web app
|
|
218
|
+
const DETAIL_STATUS_LABEL = {
|
|
219
|
+
draft: "Drafting",
|
|
220
|
+
generating: "Drafting",
|
|
221
|
+
review: "In Review",
|
|
222
|
+
published: "Published",
|
|
223
|
+
archived: "Archived",
|
|
224
|
+
};
|
|
160
225
|
let markdown = `## ${content.title || "Untitled Content"}\n\n`;
|
|
161
226
|
markdown += formatKeyValue({
|
|
162
227
|
ID: content.id,
|
|
163
|
-
Status: content.status,
|
|
228
|
+
Status: DETAIL_STATUS_LABEL[content.status] || content.status,
|
|
164
229
|
"Word Count": content.word_count,
|
|
165
230
|
Type: content.type,
|
|
166
231
|
Slug: content.slug,
|
|
@@ -172,6 +237,34 @@ export async function executeGetContent(input, context) {
|
|
|
172
237
|
Updated: content.updated_at ? formatDate(content.updated_at) : null,
|
|
173
238
|
Published: content.published_at ? formatDate(content.published_at) : null,
|
|
174
239
|
});
|
|
240
|
+
// E-E-A-T Breakdown
|
|
241
|
+
if (content.eeat_breakdown) {
|
|
242
|
+
const b = content.eeat_breakdown;
|
|
243
|
+
markdown += `\n\n### E-E-A-T Breakdown\n\n`;
|
|
244
|
+
markdown += `| Pillar | Score | Details |\n|--------|-------|--------|\n`;
|
|
245
|
+
const exp = b.experience;
|
|
246
|
+
const isV2Experience = exp?.firstPersonAction !== undefined;
|
|
247
|
+
if (isV2Experience) {
|
|
248
|
+
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`;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
markdown += `| Experience | ${exp?.score ?? 0}/20 | First-person: ${exp?.firstPersonUsage ?? 0}/8, Media: ${exp?.originalMedia ?? 0}/6, Narrative: ${exp?.narrativeEvidence ?? 0}/6 |\n`;
|
|
252
|
+
}
|
|
253
|
+
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`;
|
|
254
|
+
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`;
|
|
255
|
+
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`;
|
|
256
|
+
// Weight annotation for non-default content-type weights (PRO-2169 3E)
|
|
257
|
+
const w = b.appliedWeights;
|
|
258
|
+
const isDefault = !w || (w.trust === 47 && w.experience === 0 && w.expertise === 33 && w.authority === 20);
|
|
259
|
+
if (!isDefault && w) {
|
|
260
|
+
const label = getWeightLabel(b.appliedWeightProfile || "custom");
|
|
261
|
+
markdown += `| Applied Weights | | Trust ${w.trust ?? 0}% · Experience ${w.experience ?? 0}% · Expertise ${w.expertise ?? 0}% · Authority ${w.authority ?? 0}% |\n`;
|
|
262
|
+
markdown += `\n> Weights adjusted for ${label} content.\n`;
|
|
263
|
+
}
|
|
264
|
+
if (b.isYMYL) {
|
|
265
|
+
markdown += `\n> **YMYL content detected** — higher quality standards apply for this topic.\n`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
175
268
|
// Meta tags
|
|
176
269
|
if (content.meta_title || content.meta_description) {
|
|
177
270
|
markdown += `\n\n### Meta Tags\n\n`;
|
|
@@ -182,10 +275,25 @@ export async function executeGetContent(input, context) {
|
|
|
182
275
|
markdown += `- **Description:** ${content.meta_description}\n`;
|
|
183
276
|
}
|
|
184
277
|
}
|
|
185
|
-
//
|
|
278
|
+
// PRO-1887: Surface improvement status so Claude knows whether an async improvement is done
|
|
279
|
+
if (content.progress_details?.improvingStatus) {
|
|
280
|
+
const status = content.progress_details.improvingStatus;
|
|
281
|
+
if (status === "in_progress") {
|
|
282
|
+
markdown += `\n\n> **Improvement status:** in_progress — An AI improvement is currently being applied. Wait 15-30 seconds and call \`get_content\` again.\n`;
|
|
283
|
+
}
|
|
284
|
+
else if (status === "completed") {
|
|
285
|
+
markdown += `\n\n> **Improvement status:** completed — The AI improvement was successfully applied.\n`;
|
|
286
|
+
}
|
|
287
|
+
else if (status === "failed") {
|
|
288
|
+
markdown += `\n\n> **Improvement status:** failed — The AI improvement failed. You may retry with \`regenerate_content\`.\n`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Content body — convert HTML to readable text; pass markdown through as-is (PRO-1866)
|
|
186
292
|
if (include_body && content.body) {
|
|
187
293
|
markdown += `\n\n### Content Body\n\n`;
|
|
188
|
-
const readableBody =
|
|
294
|
+
const readableBody = isHtmlContent(content.body)
|
|
295
|
+
? htmlToReadableText(content.body)
|
|
296
|
+
: content.body;
|
|
189
297
|
// Truncate extremely long content (50K chars ≈ 8K+ words)
|
|
190
298
|
if (readableBody.length > 50000) {
|
|
191
299
|
markdown += readableBody.slice(0, 50000);
|
|
@@ -244,6 +352,27 @@ export async function executeGenerateContent(input, context) {
|
|
|
244
352
|
}
|
|
245
353
|
const { brief_id, generate_images } = parsed.data;
|
|
246
354
|
try {
|
|
355
|
+
// Check if the brief already has linked content (app hides Generate button in this case)
|
|
356
|
+
try {
|
|
357
|
+
const briefResponse = await context.client.get(`/briefs/${brief_id}`, undefined, 0);
|
|
358
|
+
if (briefResponse.data?.linked_content) {
|
|
359
|
+
const linked = briefResponse.data.linked_content;
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
markdown: `**Error:** This brief already has linked content.\n\n` +
|
|
363
|
+
formatKeyValue({
|
|
364
|
+
"Existing Content ID": linked.id,
|
|
365
|
+
Title: linked.title || "Untitled",
|
|
366
|
+
Status: formatStatus(linked.status),
|
|
367
|
+
}) +
|
|
368
|
+
`\n\nTo regenerate this content, use \`regenerate_content\` with \`content_id: "${linked.id}"\`.\n` +
|
|
369
|
+
`To improve it with specific instructions, use \`regenerate_content\` with \`content_id: "${linked.id}"\` and \`instructions\`.`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Brief lookup failed — proceed with generation anyway (API will handle validation)
|
|
375
|
+
}
|
|
247
376
|
const response = await context.client.post("/content/generate", { brief_id, generate_images });
|
|
248
377
|
const content = response.data;
|
|
249
378
|
let markdown = `## Content Generation Started\n\n`;
|
|
@@ -255,7 +384,7 @@ export async function executeGenerateContent(input, context) {
|
|
|
255
384
|
"Generate Images": generate_images ? "Yes" : "No",
|
|
256
385
|
Created: formatDate(content.created_at),
|
|
257
386
|
});
|
|
258
|
-
markdown += `\n\n> **Tip:** Use \`
|
|
387
|
+
markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track generation progress (~2-5 minutes).`;
|
|
259
388
|
return {
|
|
260
389
|
success: true,
|
|
261
390
|
markdown,
|
|
@@ -273,17 +402,23 @@ export async function executeGenerateContent(input, context) {
|
|
|
273
402
|
// ============================================
|
|
274
403
|
// update_content
|
|
275
404
|
// ============================================
|
|
405
|
+
// Map update_content display status → single DB status
|
|
406
|
+
const UPDATE_STATUS_DISPLAY_TO_DB = {
|
|
407
|
+
drafting: "draft",
|
|
408
|
+
review: "review",
|
|
409
|
+
published: "published",
|
|
410
|
+
};
|
|
276
411
|
const updateContentInputSchema = z.object({
|
|
277
412
|
id: z.string().min(1),
|
|
278
413
|
title: z.string().optional(),
|
|
279
414
|
body: z.string().optional(),
|
|
280
|
-
status: z.enum(["
|
|
415
|
+
status: z.enum(["drafting", "review", "published"]).optional(),
|
|
281
416
|
meta_title: z.string().optional(),
|
|
282
417
|
meta_description: z.string().optional(),
|
|
283
418
|
});
|
|
284
419
|
export const updateContentTool = {
|
|
285
420
|
name: "update_content",
|
|
286
|
-
description: "Update a content item's title, body, status, or meta tags.",
|
|
421
|
+
description: "Update a content item's title, body, status, or meta tags. To archive/trash content, use delete_content instead.",
|
|
287
422
|
inputSchema: {
|
|
288
423
|
type: "object",
|
|
289
424
|
properties: {
|
|
@@ -301,8 +436,8 @@ export const updateContentTool = {
|
|
|
301
436
|
},
|
|
302
437
|
status: {
|
|
303
438
|
type: "string",
|
|
304
|
-
enum: ["
|
|
305
|
-
description: "New status",
|
|
439
|
+
enum: ["drafting", "review", "published"],
|
|
440
|
+
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
441
|
},
|
|
307
442
|
meta_title: {
|
|
308
443
|
type: "string",
|
|
@@ -324,9 +459,11 @@ export async function executeUpdateContent(input, context) {
|
|
|
324
459
|
markdown: `**Error:** Invalid input - ${parsed.error.message}`,
|
|
325
460
|
};
|
|
326
461
|
}
|
|
327
|
-
const { id, ...
|
|
328
|
-
//
|
|
329
|
-
const
|
|
462
|
+
const { id, status: displayStatus, ...otherUpdates } = parsed.data;
|
|
463
|
+
// Map display status to DB status
|
|
464
|
+
const dbStatus = displayStatus ? UPDATE_STATUS_DISPLAY_TO_DB[displayStatus] : undefined;
|
|
465
|
+
// Filter out undefined values and include mapped DB status
|
|
466
|
+
const fieldsToUpdate = Object.fromEntries(Object.entries({ ...otherUpdates, status: dbStatus }).filter(([, v]) => v !== undefined));
|
|
330
467
|
if (Object.keys(fieldsToUpdate).length === 0) {
|
|
331
468
|
return {
|
|
332
469
|
success: false,
|
|
@@ -398,7 +535,7 @@ export async function executeDeleteContent(input, context) {
|
|
|
398
535
|
await context.client.delete(`/content/${id}`);
|
|
399
536
|
return {
|
|
400
537
|
success: true,
|
|
401
|
-
markdown: `## Content Deleted\n\nContent \`${id}\` has been moved to trash
|
|
538
|
+
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
539
|
};
|
|
403
540
|
}
|
|
404
541
|
catch (error) {
|
|
@@ -439,7 +576,8 @@ export async function executeGetContentStatus(input, context) {
|
|
|
439
576
|
}
|
|
440
577
|
const { id } = parsed.data;
|
|
441
578
|
try {
|
|
442
|
-
const response = await context.client.get(`/content/${id}/status`, undefined,
|
|
579
|
+
const response = await context.client.get(`/content/${id}/status`, undefined, 0 // Never cache status — this is a polling endpoint (app polls fresh every 2s)
|
|
580
|
+
);
|
|
443
581
|
const status = response.data;
|
|
444
582
|
let markdown = `## Content Status\n\n`;
|
|
445
583
|
markdown += formatKeyValue({
|
|
@@ -558,7 +696,7 @@ const regenerateContentInputSchema = z.object({
|
|
|
558
696
|
});
|
|
559
697
|
export const regenerateContentTool = {
|
|
560
698
|
name: "regenerate_content",
|
|
561
|
-
description: "Regenerate or improve existing content. Without instructions,
|
|
699
|
+
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
700
|
inputSchema: {
|
|
563
701
|
type: "object",
|
|
564
702
|
properties: {
|
|
@@ -601,11 +739,15 @@ export async function executeRegenerateContent(input, context) {
|
|
|
601
739
|
Status: formatStatus(result.status),
|
|
602
740
|
"Brief ID": result.brief_id,
|
|
603
741
|
Method: instructions ? "AI Improvement" : "Full Regeneration",
|
|
742
|
+
"Current updatedAt": result.updated_at || "unknown",
|
|
604
743
|
});
|
|
605
744
|
if (instructions) {
|
|
606
745
|
markdown += `\n\n**Instructions:** ${instructions.length > 200 ? instructions.slice(0, 200) + "..." : instructions}`;
|
|
746
|
+
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.`;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
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
750
|
}
|
|
608
|
-
markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track regeneration progress.`;
|
|
609
751
|
return {
|
|
610
752
|
success: true,
|
|
611
753
|
markdown,
|
|
@@ -655,10 +797,18 @@ export async function executeRescoreContent(input, context) {
|
|
|
655
797
|
let markdown = `## Content Rescored\n\n`;
|
|
656
798
|
// Score summary table
|
|
657
799
|
const headers = ["Score Type", "Score", "Rating"];
|
|
800
|
+
// Check for non-default EEAT weights (PRO-2169 3E)
|
|
801
|
+
const eeatBd = result.scores.eeat_breakdown;
|
|
802
|
+
const aw = eeatBd?.appliedWeights;
|
|
803
|
+
const isDefaultWeights = !aw || (aw.trust === 47 && aw.experience === 0 && aw.expertise === 33 && aw.authority === 20);
|
|
804
|
+
const weightLabel = !isDefaultWeights
|
|
805
|
+
? getWeightLabel(eeatBd?.appliedWeightProfile || "custom")
|
|
806
|
+
: null;
|
|
807
|
+
const eeatRating = getScoreRating(result.scores.eeat_score) + (weightLabel ? ` (weighted for ${weightLabel})` : "");
|
|
658
808
|
const rows = [
|
|
659
809
|
["SEO", String(result.scores.seo_score), getScoreRating(result.scores.seo_score)],
|
|
660
810
|
["GEO", String(result.scores.geo_score), getScoreRating(result.scores.geo_score)],
|
|
661
|
-
["EEAT", String(result.scores.eeat_score),
|
|
811
|
+
["EEAT", String(result.scores.eeat_score), eeatRating],
|
|
662
812
|
];
|
|
663
813
|
markdown += formatTable(headers, rows);
|
|
664
814
|
markdown += `\n\n`;
|
|
@@ -681,6 +831,14 @@ export async function executeRescoreContent(input, context) {
|
|
|
681
831
|
};
|
|
682
832
|
}
|
|
683
833
|
}
|
|
834
|
+
/** Convert weight profile key to human-readable label (shared by get_content + rescore_content) */
|
|
835
|
+
function getWeightLabel(profile) {
|
|
836
|
+
if (profile.startsWith("blog:"))
|
|
837
|
+
return profile.split(":")[1];
|
|
838
|
+
if (profile === "landing" || profile === "product")
|
|
839
|
+
return `${profile} page`;
|
|
840
|
+
return profile;
|
|
841
|
+
}
|
|
684
842
|
function getScoreRating(score) {
|
|
685
843
|
if (score >= 80)
|
|
686
844
|
return "Excellent";
|
|
@@ -693,14 +851,183 @@ function getScoreRating(score) {
|
|
|
693
851
|
return "Not Competitive";
|
|
694
852
|
}
|
|
695
853
|
// ============================================
|
|
854
|
+
// get_content_summary
|
|
855
|
+
// ============================================
|
|
856
|
+
export const getContentSummaryTool = {
|
|
857
|
+
name: "get_content_summary",
|
|
858
|
+
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.",
|
|
859
|
+
inputSchema: {
|
|
860
|
+
type: "object",
|
|
861
|
+
properties: {},
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
export async function executeGetContentSummary(_input, context) {
|
|
865
|
+
try {
|
|
866
|
+
const response = await context.client.get("/content/summary", undefined, CACHE_TTL.lists);
|
|
867
|
+
const { status_counts, total } = response.data;
|
|
868
|
+
// Build LLM-friendly text output
|
|
869
|
+
let markdown = `## Content Summary\n\n`;
|
|
870
|
+
markdown += `**Total:** ${total} content item${total === 1 ? "" : "s"}\n\n`;
|
|
871
|
+
if (status_counts.length === 0) {
|
|
872
|
+
markdown += `No content items found.\n`;
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
// Sort by count descending for readability
|
|
876
|
+
const sorted = [...status_counts].sort((a, b) => b.count - a.count);
|
|
877
|
+
for (const { status, count } of sorted) {
|
|
878
|
+
markdown += `- **${formatStatus(status)}:** ${count}\n`;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
success: true,
|
|
883
|
+
markdown,
|
|
884
|
+
data: response,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
889
|
+
return {
|
|
890
|
+
success: false,
|
|
891
|
+
markdown: `**Error:** Failed to get content summary - ${message}`,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// ============================================
|
|
896
|
+
// restore_content
|
|
897
|
+
// ============================================
|
|
898
|
+
const restoreContentInputSchema = z.object({
|
|
899
|
+
id: z.string().min(1),
|
|
900
|
+
});
|
|
901
|
+
export const restoreContentTool = {
|
|
902
|
+
name: "restore_content",
|
|
903
|
+
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.",
|
|
904
|
+
inputSchema: {
|
|
905
|
+
type: "object",
|
|
906
|
+
properties: {
|
|
907
|
+
id: {
|
|
908
|
+
type: "string",
|
|
909
|
+
description: "The content ID to restore (required)",
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
required: ["id"],
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
export async function executeRestoreContent(input, context) {
|
|
916
|
+
const parsed = restoreContentInputSchema.safeParse(input);
|
|
917
|
+
if (!parsed.success) {
|
|
918
|
+
return {
|
|
919
|
+
success: false,
|
|
920
|
+
markdown: `**Error:** Invalid input - ${parsed.error.message}`,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const { id } = parsed.data;
|
|
924
|
+
try {
|
|
925
|
+
const response = await context.client.post(`/content/${id}/restore`, {});
|
|
926
|
+
const content = response.data;
|
|
927
|
+
return {
|
|
928
|
+
success: true,
|
|
929
|
+
markdown: `## Content Restored\n\n${formatKeyValue({
|
|
930
|
+
ID: content.id,
|
|
931
|
+
Title: content.title,
|
|
932
|
+
Status: formatStatus(content.status),
|
|
933
|
+
})}\n\nThe content has been restored from trash and is available again.`,
|
|
934
|
+
data: response,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
939
|
+
return {
|
|
940
|
+
success: false,
|
|
941
|
+
markdown: `**Error:** Failed to restore content - ${message}`,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// ============================================
|
|
946
|
+
// list_trash
|
|
947
|
+
// ============================================
|
|
948
|
+
const listTrashInputSchema = z.object({
|
|
949
|
+
limit: z.number().min(1).max(100).optional().default(20),
|
|
950
|
+
offset: z.number().min(0).optional().default(0),
|
|
951
|
+
site_id: z.string().optional(),
|
|
952
|
+
});
|
|
953
|
+
export const listTrashTool = {
|
|
954
|
+
name: "list_trash",
|
|
955
|
+
description: "List trashed/deleted content items. Returns content that has been soft-deleted (moved to trash).",
|
|
956
|
+
inputSchema: {
|
|
957
|
+
type: "object",
|
|
958
|
+
properties: {
|
|
959
|
+
limit: {
|
|
960
|
+
type: "number",
|
|
961
|
+
description: "Max items to return, max 100 (default: 20)",
|
|
962
|
+
},
|
|
963
|
+
offset: {
|
|
964
|
+
type: "number",
|
|
965
|
+
description: "Number of items to skip for pagination (default: 0)",
|
|
966
|
+
},
|
|
967
|
+
site_id: {
|
|
968
|
+
type: "string",
|
|
969
|
+
description: "Filter by site ID. Use list_sites to get available site IDs.",
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
export async function executeListTrash(input, context) {
|
|
975
|
+
const parsed = listTrashInputSchema.safeParse(input || {});
|
|
976
|
+
if (!parsed.success) {
|
|
977
|
+
return {
|
|
978
|
+
success: false,
|
|
979
|
+
markdown: `**Error:** Invalid input - ${parsed.error.message}`,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const { limit, offset, site_id } = parsed.data;
|
|
983
|
+
// Convert limit/offset to page/page_size for the API
|
|
984
|
+
const page = Math.floor(offset / limit) + 1;
|
|
985
|
+
try {
|
|
986
|
+
const params = {
|
|
987
|
+
page,
|
|
988
|
+
page_size: limit,
|
|
989
|
+
status: "archived",
|
|
990
|
+
};
|
|
991
|
+
if (site_id)
|
|
992
|
+
params.site_id = site_id;
|
|
993
|
+
const response = await context.client.get("/content", params, 0 // No cache — ensure freshness after recent deletes/restores
|
|
994
|
+
);
|
|
995
|
+
const headers = ["ID", "Title", "Status", "Words", "Updated"];
|
|
996
|
+
const rows = response.data.map((c) => [
|
|
997
|
+
c.id,
|
|
998
|
+
truncate(c.title || "Untitled", 40),
|
|
999
|
+
formatStatus(c.status),
|
|
1000
|
+
formatWordCount(c.word_count),
|
|
1001
|
+
c.updated_at ? formatDate(c.updated_at) : formatDate(c.created_at),
|
|
1002
|
+
]);
|
|
1003
|
+
const table = formatTable(headers, rows);
|
|
1004
|
+
const pagination = formatPagination(response.pagination);
|
|
1005
|
+
return {
|
|
1006
|
+
success: true,
|
|
1007
|
+
markdown: `## Trash (Page ${page})\n\n${table}${pagination}\n\n> **Tip:** Use \`restore_content\` with a content ID to recover an item from trash.`,
|
|
1008
|
+
data: response,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1013
|
+
return {
|
|
1014
|
+
success: false,
|
|
1015
|
+
markdown: `**Error:** Failed to list trashed content - ${message}`,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// ============================================
|
|
696
1020
|
// Export all content tools
|
|
697
1021
|
// ============================================
|
|
698
1022
|
export const contentTools = [
|
|
699
1023
|
listContentTool,
|
|
1024
|
+
listTrashTool,
|
|
700
1025
|
getContentTool,
|
|
1026
|
+
getContentSummaryTool,
|
|
701
1027
|
generateContentTool,
|
|
702
1028
|
updateContentTool,
|
|
703
1029
|
deleteContentTool,
|
|
1030
|
+
restoreContentTool,
|
|
704
1031
|
getContentStatusTool,
|
|
705
1032
|
exportContentTool,
|
|
706
1033
|
regenerateContentTool,
|
|
@@ -708,10 +1035,13 @@ export const contentTools = [
|
|
|
708
1035
|
];
|
|
709
1036
|
export const contentExecutors = {
|
|
710
1037
|
list_content: executeListContent,
|
|
1038
|
+
list_trash: executeListTrash,
|
|
711
1039
|
get_content: executeGetContent,
|
|
1040
|
+
get_content_summary: executeGetContentSummary,
|
|
712
1041
|
generate_content: executeGenerateContent,
|
|
713
1042
|
update_content: executeUpdateContent,
|
|
714
1043
|
delete_content: executeDeleteContent,
|
|
1044
|
+
restore_content: executeRestoreContent,
|
|
715
1045
|
get_content_status: executeGetContentStatus,
|
|
716
1046
|
export_content: executeExportContent,
|
|
717
1047
|
regenerate_content: executeRegenerateContent,
|