@frase/mcp-server 0.2.1 → 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.
- package/dist/api-client.d.ts +3 -90
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +2 -214
- package/dist/api-client.js.map +1 -1
- package/dist/cache.d.ts +2 -49
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +2 -94
- package/dist/cache.js.map +1 -1
- package/dist/cli/config-writer.d.ts.map +1 -1
- package/dist/cli/config-writer.js +52 -7
- package/dist/cli/config-writer.js.map +1 -1
- package/dist/config.d.ts +3 -14
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -28
- package/dist/config.js.map +1 -1
- package/dist/formatter.d.ts +2 -44
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +2 -142
- package/dist/formatter.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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/cms-publishing.d.ts +16 -0
- package/dist/prompts/cms-publishing.d.ts.map +1 -0
- package/dist/prompts/cms-publishing.js +84 -0
- package/dist/prompts/cms-publishing.js.map +1 -0
- package/dist/prompts/competitive-intelligence.d.ts +16 -0
- package/dist/prompts/competitive-intelligence.d.ts.map +1 -0
- package/dist/prompts/competitive-intelligence.js +78 -0
- package/dist/prompts/competitive-intelligence.js.map +1 -0
- package/dist/prompts/content-governance.d.ts +16 -0
- package/dist/prompts/content-governance.d.ts.map +1 -0
- package/dist/prompts/content-governance.js +113 -0
- package/dist/prompts/content-governance.js.map +1 -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/geo-optimization.d.ts +17 -0
- package/dist/prompts/geo-optimization.d.ts.map +1 -0
- package/dist/prompts/geo-optimization.js +79 -0
- package/dist/prompts/geo-optimization.js.map +1 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +15 -0
- package/dist/prompts/index.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/atomization.d.ts +8 -0
- package/dist/tools/atomization.d.ts.map +1 -0
- package/dist/tools/atomization.js +172 -0
- package/dist/tools/atomization.js.map +1 -0
- package/dist/tools/audits.d.ts +8 -5
- package/dist/tools/audits.d.ts.map +1 -1
- package/dist/tools/audits.js +113 -22
- 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 +212 -163
- package/dist/tools/briefs.js.map +1 -1
- package/dist/tools/clusters.d.ts +9 -0
- package/dist/tools/clusters.d.ts.map +1 -0
- package/dist/tools/clusters.js +166 -0
- package/dist/tools/clusters.js.map +1 -0
- package/dist/tools/cms-connections.d.ts +7 -0
- package/dist/tools/cms-connections.d.ts.map +1 -0
- package/dist/tools/cms-connections.js +96 -0
- package/dist/tools/cms-connections.js.map +1 -0
- package/dist/tools/competitive-analysis.js +2 -2
- package/dist/tools/competitive-analysis.js.map +1 -1
- package/dist/tools/content.d.ts +36 -8
- package/dist/tools/content.d.ts.map +1 -1
- package/dist/tools/content.js +415 -30
- package/dist/tools/content.js.map +1 -1
- package/dist/tools/discover.d.ts +15 -0
- package/dist/tools/discover.d.ts.map +1 -0
- package/dist/tools/discover.js +180 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +32 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/jobs.d.ts +2 -2
- package/dist/tools/opportunities.d.ts +11 -0
- package/dist/tools/opportunities.d.ts.map +1 -0
- package/dist/tools/opportunities.js +122 -0
- package/dist/tools/opportunities.js.map +1 -0
- package/dist/tools/optimizations.d.ts +4 -0
- package/dist/tools/optimizations.d.ts.map +1 -1
- package/dist/tools/optimizations.js +359 -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.d.ts +7 -0
- package/dist/tools/site-health.d.ts.map +1 -0
- package/dist/tools/site-health.js +120 -0
- package/dist/tools/site-health.js.map +1 -0
- 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 +2 -180
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/package.json +30 -14
- package/server.json +4 -4
package/dist/tools/content.js
CHANGED
|
@@ -1,21 +1,90 @@
|
|
|
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
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Convert HTML body to readable markdown-like text.
|
|
13
|
+
* Preserves headings, paragraphs, lists, and links while stripping raw HTML.
|
|
14
|
+
* Lightweight — no external dependency needed.
|
|
15
|
+
*/
|
|
16
|
+
function htmlToReadableText(html) {
|
|
17
|
+
let text = html;
|
|
18
|
+
// Convert headings to markdown
|
|
19
|
+
text = text.replace(/<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gis, (_m, level, content) => {
|
|
20
|
+
return "\n" + "#".repeat(parseInt(level)) + " " + content.trim() + "\n";
|
|
21
|
+
});
|
|
22
|
+
// Convert <br> to newlines
|
|
23
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
24
|
+
// Convert <p> to double newlines
|
|
25
|
+
text = text.replace(/<\/p>/gi, "\n\n");
|
|
26
|
+
text = text.replace(/<p[^>]*>/gi, "");
|
|
27
|
+
// Convert list items
|
|
28
|
+
text = text.replace(/<li[^>]*>/gi, "- ");
|
|
29
|
+
text = text.replace(/<\/li>/gi, "\n");
|
|
30
|
+
// Convert <a> to markdown links
|
|
31
|
+
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, "[$2]($1)");
|
|
32
|
+
// Convert bold/italic
|
|
33
|
+
text = text.replace(/<(strong|b)[^>]*>(.*?)<\/(strong|b)>/gis, "**$2**");
|
|
34
|
+
text = text.replace(/<(em|i)[^>]*>(.*?)<\/(em|i)>/gis, "*$2*");
|
|
35
|
+
// Convert blockquotes
|
|
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");
|
|
47
|
+
// Add spacing around div/section blocks to prevent text running together
|
|
48
|
+
text = text.replace(/<\/(div|section|article|aside|header|footer|figure|figcaption)>/gi, "\n");
|
|
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');
|
|
52
|
+
// Strip remaining HTML tags
|
|
53
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
54
|
+
// Decode common HTML entities
|
|
55
|
+
text = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
56
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ")
|
|
57
|
+
.replace(/'/g, "'").replace(/—/g, "—").replace(/–/g, "–")
|
|
58
|
+
.replace(/…/g, "…").replace(/’/g, "'").replace(/‘/g, "'")
|
|
59
|
+
.replace(/”/g, "\u201D").replace(/“/g, "\u201C");
|
|
60
|
+
// Clean up excessive whitespace
|
|
61
|
+
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
4
64
|
import { z } from "zod";
|
|
5
65
|
import { CACHE_TTL } from "../cache.js";
|
|
6
66
|
import { formatTable, formatStatus, formatDate, formatPagination, formatKeyValue, formatWordCount, truncate, } from "../formatter.js";
|
|
7
67
|
// ============================================
|
|
8
68
|
// list_content
|
|
9
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
|
+
};
|
|
10
79
|
const listContentInputSchema = z.object({
|
|
11
80
|
page: z.number().min(1).optional().default(1),
|
|
12
81
|
page_size: z.number().min(1).max(100).optional().default(20),
|
|
13
|
-
status: z.enum(
|
|
82
|
+
status: z.enum(CONTENT_DISPLAY_STATUS_VALUES).optional(),
|
|
14
83
|
site_id: z.string().optional(),
|
|
15
84
|
});
|
|
16
85
|
export const listContentTool = {
|
|
17
86
|
name: "list_content",
|
|
18
|
-
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.",
|
|
19
88
|
inputSchema: {
|
|
20
89
|
type: "object",
|
|
21
90
|
properties: {
|
|
@@ -29,8 +98,8 @@ export const listContentTool = {
|
|
|
29
98
|
},
|
|
30
99
|
status: {
|
|
31
100
|
type: "string",
|
|
32
|
-
enum: ["
|
|
33
|
-
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).",
|
|
34
103
|
},
|
|
35
104
|
site_id: {
|
|
36
105
|
type: "string",
|
|
@@ -49,17 +118,36 @@ export async function executeListContent(input, context) {
|
|
|
49
118
|
}
|
|
50
119
|
const { page, page_size, status, site_id } = parsed.data;
|
|
51
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;
|
|
52
126
|
const params = { page, page_size };
|
|
53
|
-
if (
|
|
54
|
-
params.status =
|
|
127
|
+
if (singleDbStatus)
|
|
128
|
+
params.status = singleDbStatus;
|
|
55
129
|
if (site_id)
|
|
56
130
|
params.site_id = site_id;
|
|
57
|
-
|
|
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
|
+
};
|
|
58
146
|
const headers = ["ID", "Title", "Status", "Words", "Updated"];
|
|
59
|
-
const rows =
|
|
147
|
+
const rows = filteredData.map((c) => [
|
|
60
148
|
c.id,
|
|
61
149
|
truncate(c.title || "Untitled", 40),
|
|
62
|
-
formatStatus(c.status),
|
|
150
|
+
DB_STATUS_TO_LABEL[c.status] || formatStatus(c.status),
|
|
63
151
|
formatWordCount(c.word_count),
|
|
64
152
|
c.updated_at ? formatDate(c.updated_at) : formatDate(c.created_at),
|
|
65
153
|
]);
|
|
@@ -114,20 +202,60 @@ export async function executeGetContent(input, context) {
|
|
|
114
202
|
}
|
|
115
203
|
const { id, include_body } = parsed.data;
|
|
116
204
|
try {
|
|
117
|
-
const response = await context.client.get(`/content/${id}`, undefined,
|
|
205
|
+
const response = await context.client.get(`/content/${id}`, undefined, 0 // Never cache content — body changes after regeneration/improvement
|
|
206
|
+
);
|
|
118
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
|
+
};
|
|
119
216
|
let markdown = `## ${content.title || "Untitled Content"}\n\n`;
|
|
120
217
|
markdown += formatKeyValue({
|
|
121
218
|
ID: content.id,
|
|
122
|
-
Status: content.status,
|
|
219
|
+
Status: DETAIL_STATUS_LABEL[content.status] || content.status,
|
|
123
220
|
"Word Count": content.word_count,
|
|
124
221
|
Type: content.type,
|
|
125
222
|
Slug: content.slug,
|
|
126
223
|
Keywords: content.target_keywords?.join(", ") || "None",
|
|
224
|
+
"SEO Score": content.seo_score != null ? `${content.seo_score}/100` : null,
|
|
225
|
+
"GEO Score": content.geo_score != null ? `${content.geo_score}/100` : null,
|
|
226
|
+
"E-E-A-T Score": content.eeat_score != null ? `${content.eeat_score}/100` : null,
|
|
127
227
|
Created: formatDate(content.created_at),
|
|
128
228
|
Updated: content.updated_at ? formatDate(content.updated_at) : null,
|
|
129
229
|
Published: content.published_at ? formatDate(content.published_at) : null,
|
|
130
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
|
+
}
|
|
131
259
|
// Meta tags
|
|
132
260
|
if (content.meta_title || content.meta_description) {
|
|
133
261
|
markdown += `\n\n### Meta Tags\n\n`;
|
|
@@ -138,16 +266,32 @@ export async function executeGetContent(input, context) {
|
|
|
138
266
|
markdown += `- **Description:** ${content.meta_description}\n`;
|
|
139
267
|
}
|
|
140
268
|
}
|
|
141
|
-
//
|
|
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)
|
|
142
283
|
if (include_body && content.body) {
|
|
143
284
|
markdown += `\n\n### Content Body\n\n`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
285
|
+
const readableBody = isHtmlContent(content.body)
|
|
286
|
+
? htmlToReadableText(content.body)
|
|
287
|
+
: content.body;
|
|
288
|
+
// Truncate extremely long content (50K chars ≈ 8K+ words)
|
|
289
|
+
if (readableBody.length > 50000) {
|
|
290
|
+
markdown += readableBody.slice(0, 50000);
|
|
291
|
+
markdown += `\n\n*... (truncated at 50,000 characters, ${content.word_count || 'unknown'} words total)*`;
|
|
148
292
|
}
|
|
149
293
|
else {
|
|
150
|
-
markdown +=
|
|
294
|
+
markdown += readableBody;
|
|
151
295
|
}
|
|
152
296
|
}
|
|
153
297
|
return {
|
|
@@ -199,6 +343,27 @@ export async function executeGenerateContent(input, context) {
|
|
|
199
343
|
}
|
|
200
344
|
const { brief_id, generate_images } = parsed.data;
|
|
201
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
|
+
}
|
|
202
367
|
const response = await context.client.post("/content/generate", { brief_id, generate_images });
|
|
203
368
|
const content = response.data;
|
|
204
369
|
let markdown = `## Content Generation Started\n\n`;
|
|
@@ -210,7 +375,7 @@ export async function executeGenerateContent(input, context) {
|
|
|
210
375
|
"Generate Images": generate_images ? "Yes" : "No",
|
|
211
376
|
Created: formatDate(content.created_at),
|
|
212
377
|
});
|
|
213
|
-
markdown += `\n\n> **Tip:** Use \`
|
|
378
|
+
markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track generation progress (~2-5 minutes).`;
|
|
214
379
|
return {
|
|
215
380
|
success: true,
|
|
216
381
|
markdown,
|
|
@@ -228,17 +393,23 @@ export async function executeGenerateContent(input, context) {
|
|
|
228
393
|
// ============================================
|
|
229
394
|
// update_content
|
|
230
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
|
+
};
|
|
231
402
|
const updateContentInputSchema = z.object({
|
|
232
403
|
id: z.string().min(1),
|
|
233
404
|
title: z.string().optional(),
|
|
234
405
|
body: z.string().optional(),
|
|
235
|
-
status: z.enum(["
|
|
406
|
+
status: z.enum(["drafting", "review", "published"]).optional(),
|
|
236
407
|
meta_title: z.string().optional(),
|
|
237
408
|
meta_description: z.string().optional(),
|
|
238
409
|
});
|
|
239
410
|
export const updateContentTool = {
|
|
240
411
|
name: "update_content",
|
|
241
|
-
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.",
|
|
242
413
|
inputSchema: {
|
|
243
414
|
type: "object",
|
|
244
415
|
properties: {
|
|
@@ -256,8 +427,8 @@ export const updateContentTool = {
|
|
|
256
427
|
},
|
|
257
428
|
status: {
|
|
258
429
|
type: "string",
|
|
259
|
-
enum: ["
|
|
260
|
-
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.",
|
|
261
432
|
},
|
|
262
433
|
meta_title: {
|
|
263
434
|
type: "string",
|
|
@@ -279,9 +450,30 @@ export async function executeUpdateContent(input, context) {
|
|
|
279
450
|
markdown: `**Error:** Invalid input - ${parsed.error.message}`,
|
|
280
451
|
};
|
|
281
452
|
}
|
|
282
|
-
const { id, ...
|
|
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));
|
|
458
|
+
if (Object.keys(fieldsToUpdate).length === 0) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
markdown: "**Error:** No update fields provided. Specify at least one of: title, body, status, meta_title, meta_description.",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
283
464
|
try {
|
|
284
|
-
|
|
465
|
+
// Fetch current content to preserve fields not being updated (PUT replaces all fields)
|
|
466
|
+
const current = await context.client.get(`/content/${id}`, undefined, 0 // no cache — need fresh data
|
|
467
|
+
);
|
|
468
|
+
const mergedBody = {
|
|
469
|
+
title: current.data.title,
|
|
470
|
+
body: current.data.body,
|
|
471
|
+
status: current.data.status,
|
|
472
|
+
meta_title: current.data.meta_title,
|
|
473
|
+
meta_description: current.data.meta_description,
|
|
474
|
+
...fieldsToUpdate,
|
|
475
|
+
};
|
|
476
|
+
const response = await context.client.put(`/content/${id}`, mergedBody);
|
|
285
477
|
const content = response.data;
|
|
286
478
|
return {
|
|
287
479
|
success: true,
|
|
@@ -334,7 +526,7 @@ export async function executeDeleteContent(input, context) {
|
|
|
334
526
|
await context.client.delete(`/content/${id}`);
|
|
335
527
|
return {
|
|
336
528
|
success: true,
|
|
337
|
-
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"\`.`,
|
|
338
530
|
};
|
|
339
531
|
}
|
|
340
532
|
catch (error) {
|
|
@@ -375,7 +567,8 @@ export async function executeGetContentStatus(input, context) {
|
|
|
375
567
|
}
|
|
376
568
|
const { id } = parsed.data;
|
|
377
569
|
try {
|
|
378
|
-
const response = await context.client.get(`/content/${id}/status`, undefined,
|
|
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
|
+
);
|
|
379
572
|
const status = response.data;
|
|
380
573
|
let markdown = `## Content Status\n\n`;
|
|
381
574
|
markdown += formatKeyValue({
|
|
@@ -494,7 +687,7 @@ const regenerateContentInputSchema = z.object({
|
|
|
494
687
|
});
|
|
495
688
|
export const regenerateContentTool = {
|
|
496
689
|
name: "regenerate_content",
|
|
497
|
-
description: "Regenerate or improve existing content. Without instructions,
|
|
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.",
|
|
498
691
|
inputSchema: {
|
|
499
692
|
type: "object",
|
|
500
693
|
properties: {
|
|
@@ -504,7 +697,7 @@ export const regenerateContentTool = {
|
|
|
504
697
|
},
|
|
505
698
|
instructions: {
|
|
506
699
|
type: "string",
|
|
507
|
-
description: "Improvement instructions for AI rewriting (max 2000 chars). If omitted, content is fully regenerated from its brief.",
|
|
700
|
+
description: "Improvement instructions for AI rewriting (max 2000 chars). If omitted, content is fully regenerated from its brief. Tip: include 'maintain word count' or 'keep the same length' to preserve article length.",
|
|
508
701
|
},
|
|
509
702
|
brief_id: {
|
|
510
703
|
type: "string",
|
|
@@ -537,11 +730,15 @@ export async function executeRegenerateContent(input, context) {
|
|
|
537
730
|
Status: formatStatus(result.status),
|
|
538
731
|
"Brief ID": result.brief_id,
|
|
539
732
|
Method: instructions ? "AI Improvement" : "Full Regeneration",
|
|
733
|
+
"Current updatedAt": result.updated_at || "unknown",
|
|
540
734
|
});
|
|
541
735
|
if (instructions) {
|
|
542
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.`;
|
|
543
741
|
}
|
|
544
|
-
markdown += `\n\n> **Tip:** Use \`get_content_status\` with the content ID to track regeneration progress.`;
|
|
545
742
|
return {
|
|
546
743
|
success: true,
|
|
547
744
|
markdown,
|
|
@@ -591,10 +788,18 @@ export async function executeRescoreContent(input, context) {
|
|
|
591
788
|
let markdown = `## Content Rescored\n\n`;
|
|
592
789
|
// Score summary table
|
|
593
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})` : "");
|
|
594
799
|
const rows = [
|
|
595
800
|
["SEO", String(result.scores.seo_score), getScoreRating(result.scores.seo_score)],
|
|
596
801
|
["GEO", String(result.scores.geo_score), getScoreRating(result.scores.geo_score)],
|
|
597
|
-
["EEAT", String(result.scores.eeat_score),
|
|
802
|
+
["EEAT", String(result.scores.eeat_score), eeatRating],
|
|
598
803
|
];
|
|
599
804
|
markdown += formatTable(headers, rows);
|
|
600
805
|
markdown += `\n\n`;
|
|
@@ -617,6 +822,14 @@ export async function executeRescoreContent(input, context) {
|
|
|
617
822
|
};
|
|
618
823
|
}
|
|
619
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
|
+
}
|
|
620
833
|
function getScoreRating(score) {
|
|
621
834
|
if (score >= 80)
|
|
622
835
|
return "Excellent";
|
|
@@ -629,14 +842,183 @@ function getScoreRating(score) {
|
|
|
629
842
|
return "Not Competitive";
|
|
630
843
|
}
|
|
631
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
|
+
// ============================================
|
|
632
1011
|
// Export all content tools
|
|
633
1012
|
// ============================================
|
|
634
1013
|
export const contentTools = [
|
|
635
1014
|
listContentTool,
|
|
1015
|
+
listTrashTool,
|
|
636
1016
|
getContentTool,
|
|
1017
|
+
getContentSummaryTool,
|
|
637
1018
|
generateContentTool,
|
|
638
1019
|
updateContentTool,
|
|
639
1020
|
deleteContentTool,
|
|
1021
|
+
restoreContentTool,
|
|
640
1022
|
getContentStatusTool,
|
|
641
1023
|
exportContentTool,
|
|
642
1024
|
regenerateContentTool,
|
|
@@ -644,10 +1026,13 @@ export const contentTools = [
|
|
|
644
1026
|
];
|
|
645
1027
|
export const contentExecutors = {
|
|
646
1028
|
list_content: executeListContent,
|
|
1029
|
+
list_trash: executeListTrash,
|
|
647
1030
|
get_content: executeGetContent,
|
|
1031
|
+
get_content_summary: executeGetContentSummary,
|
|
648
1032
|
generate_content: executeGenerateContent,
|
|
649
1033
|
update_content: executeUpdateContent,
|
|
650
1034
|
delete_content: executeDeleteContent,
|
|
1035
|
+
restore_content: executeRestoreContent,
|
|
651
1036
|
get_content_status: executeGetContentStatus,
|
|
652
1037
|
export_content: executeExportContent,
|
|
653
1038
|
regenerate_content: executeRegenerateContent,
|