@socialneuron/mcp-server 1.7.3 → 1.7.5
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/CHANGELOG.md +153 -17
- package/README.md +79 -19
- package/dist/http.js +1623 -421
- package/dist/index.js +1600 -394
- package/package.json +18 -5
- package/tools.lock.json +88 -0
package/dist/http.js
CHANGED
|
@@ -570,6 +570,8 @@ var TOOL_SCOPES = {
|
|
|
570
570
|
get_brand_runtime: "mcp:read",
|
|
571
571
|
explain_brand_system: "mcp:read",
|
|
572
572
|
check_brand_consistency: "mcp:read",
|
|
573
|
+
audit_brand_colors: "mcp:read",
|
|
574
|
+
export_design_tokens: "mcp:read",
|
|
573
575
|
get_ideation_context: "mcp:read",
|
|
574
576
|
get_credit_balance: "mcp:read",
|
|
575
577
|
get_budget_status: "mcp:read",
|
|
@@ -591,6 +593,7 @@ var TOOL_SCOPES = {
|
|
|
591
593
|
create_storyboard: "mcp:write",
|
|
592
594
|
generate_voiceover: "mcp:write",
|
|
593
595
|
generate_carousel: "mcp:write",
|
|
596
|
+
create_carousel: "mcp:write",
|
|
594
597
|
upload_media: "mcp:write",
|
|
595
598
|
// mcp:read (media)
|
|
596
599
|
get_media_url: "mcp:read",
|
|
@@ -609,6 +612,11 @@ var TOOL_SCOPES = {
|
|
|
609
612
|
list_autopilot_configs: "mcp:autopilot",
|
|
610
613
|
update_autopilot_config: "mcp:autopilot",
|
|
611
614
|
get_autopilot_status: "mcp:autopilot",
|
|
615
|
+
// Recipes
|
|
616
|
+
list_recipes: "mcp:read",
|
|
617
|
+
get_recipe_details: "mcp:read",
|
|
618
|
+
execute_recipe: "mcp:write",
|
|
619
|
+
get_recipe_run_status: "mcp:read",
|
|
612
620
|
// mcp:read (content lifecycle — read-only tools)
|
|
613
621
|
extract_url_content: "mcp:read",
|
|
614
622
|
quality_check: "mcp:read",
|
|
@@ -1375,7 +1383,7 @@ init_supabase();
|
|
|
1375
1383
|
init_request_context();
|
|
1376
1384
|
|
|
1377
1385
|
// src/lib/version.ts
|
|
1378
|
-
var MCP_VERSION = "1.7.
|
|
1386
|
+
var MCP_VERSION = "1.7.5";
|
|
1379
1387
|
|
|
1380
1388
|
// src/tools/content.ts
|
|
1381
1389
|
var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
|
|
@@ -2543,7 +2551,7 @@ var ERROR_PATTERNS = [
|
|
|
2543
2551
|
// Generic sensitive patterns (API keys, URLs with secrets)
|
|
2544
2552
|
[/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
|
|
2545
2553
|
];
|
|
2546
|
-
function
|
|
2554
|
+
function sanitizeError(error) {
|
|
2547
2555
|
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
|
2548
2556
|
if (process.env.NODE_ENV !== "production") {
|
|
2549
2557
|
console.error("[Error]", msg);
|
|
@@ -2556,6 +2564,171 @@ function sanitizeError2(error) {
|
|
|
2556
2564
|
return "An unexpected error occurred. Please try again.";
|
|
2557
2565
|
}
|
|
2558
2566
|
|
|
2567
|
+
// src/lib/ssrf.ts
|
|
2568
|
+
var BLOCKED_IP_PATTERNS = [
|
|
2569
|
+
// IPv4 localhost/loopback
|
|
2570
|
+
/^127\./,
|
|
2571
|
+
/^0\./,
|
|
2572
|
+
// IPv4 private ranges (RFC 1918)
|
|
2573
|
+
/^10\./,
|
|
2574
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
2575
|
+
/^192\.168\./,
|
|
2576
|
+
// IPv4 link-local
|
|
2577
|
+
/^169\.254\./,
|
|
2578
|
+
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
2579
|
+
/^169\.254\.169\.254$/,
|
|
2580
|
+
// IPv4 broadcast
|
|
2581
|
+
/^255\./,
|
|
2582
|
+
// Shared address space (RFC 6598)
|
|
2583
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
2584
|
+
];
|
|
2585
|
+
var BLOCKED_IPV6_PATTERNS = [
|
|
2586
|
+
/^::1$/i,
|
|
2587
|
+
// loopback
|
|
2588
|
+
/^::$/i,
|
|
2589
|
+
// unspecified
|
|
2590
|
+
/^fe[89ab][0-9a-f]:/i,
|
|
2591
|
+
// link-local fe80::/10
|
|
2592
|
+
/^fc[0-9a-f]:/i,
|
|
2593
|
+
// unique local fc00::/7
|
|
2594
|
+
/^fd[0-9a-f]:/i,
|
|
2595
|
+
// unique local fc00::/7
|
|
2596
|
+
/^::ffff:127\./i,
|
|
2597
|
+
// IPv4-mapped localhost
|
|
2598
|
+
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
2599
|
+
// IPv4-mapped private
|
|
2600
|
+
];
|
|
2601
|
+
var BLOCKED_HOSTNAMES = [
|
|
2602
|
+
"localhost",
|
|
2603
|
+
"localhost.localdomain",
|
|
2604
|
+
"local",
|
|
2605
|
+
"127.0.0.1",
|
|
2606
|
+
"0.0.0.0",
|
|
2607
|
+
"[::1]",
|
|
2608
|
+
"[::ffff:127.0.0.1]",
|
|
2609
|
+
// Cloud metadata endpoints
|
|
2610
|
+
"metadata.google.internal",
|
|
2611
|
+
"metadata.goog",
|
|
2612
|
+
"instance-data",
|
|
2613
|
+
"instance-data.ec2.internal"
|
|
2614
|
+
];
|
|
2615
|
+
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
2616
|
+
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
2617
|
+
function isBlockedIP(ip) {
|
|
2618
|
+
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
2619
|
+
if (normalized.includes(":")) {
|
|
2620
|
+
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
2621
|
+
}
|
|
2622
|
+
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
2623
|
+
}
|
|
2624
|
+
function isBlockedHostname(hostname) {
|
|
2625
|
+
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
2626
|
+
}
|
|
2627
|
+
function isIPAddress(hostname) {
|
|
2628
|
+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
2629
|
+
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
2630
|
+
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
2631
|
+
}
|
|
2632
|
+
async function validateUrlForSSRF(urlString) {
|
|
2633
|
+
try {
|
|
2634
|
+
const url = new URL(urlString);
|
|
2635
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
2636
|
+
return {
|
|
2637
|
+
isValid: false,
|
|
2638
|
+
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
if (url.username || url.password) {
|
|
2642
|
+
return {
|
|
2643
|
+
isValid: false,
|
|
2644
|
+
error: "URLs with embedded credentials are not allowed."
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
const hostname = url.hostname.toLowerCase();
|
|
2648
|
+
if (isBlockedHostname(hostname)) {
|
|
2649
|
+
return {
|
|
2650
|
+
isValid: false,
|
|
2651
|
+
error: "Access to internal/localhost addresses is not allowed."
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
2655
|
+
return {
|
|
2656
|
+
isValid: false,
|
|
2657
|
+
error: "Access to private/internal IP addresses is not allowed."
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
2661
|
+
if (BLOCKED_PORTS.includes(port)) {
|
|
2662
|
+
return {
|
|
2663
|
+
isValid: false,
|
|
2664
|
+
error: `Access to port ${port} is not allowed.`
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
let resolvedIP;
|
|
2668
|
+
if (!isIPAddress(hostname)) {
|
|
2669
|
+
try {
|
|
2670
|
+
const dns = await import("node:dns");
|
|
2671
|
+
const resolver = new dns.promises.Resolver();
|
|
2672
|
+
const resolvedIPs = [];
|
|
2673
|
+
try {
|
|
2674
|
+
const aRecords = await resolver.resolve4(hostname);
|
|
2675
|
+
resolvedIPs.push(...aRecords);
|
|
2676
|
+
} catch {
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
const aaaaRecords = await resolver.resolve6(hostname);
|
|
2680
|
+
resolvedIPs.push(...aaaaRecords);
|
|
2681
|
+
} catch {
|
|
2682
|
+
}
|
|
2683
|
+
if (resolvedIPs.length === 0) {
|
|
2684
|
+
return {
|
|
2685
|
+
isValid: false,
|
|
2686
|
+
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
for (const ip of resolvedIPs) {
|
|
2690
|
+
if (isBlockedIP(ip)) {
|
|
2691
|
+
return {
|
|
2692
|
+
isValid: false,
|
|
2693
|
+
error: "Hostname resolves to a private/internal IP address."
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
resolvedIP = resolvedIPs[0];
|
|
2698
|
+
} catch {
|
|
2699
|
+
return {
|
|
2700
|
+
isValid: false,
|
|
2701
|
+
error: "DNS resolution failed. Cannot verify hostname safety."
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
return {
|
|
2708
|
+
isValid: false,
|
|
2709
|
+
error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
function quickSSRFCheck(urlString) {
|
|
2714
|
+
try {
|
|
2715
|
+
const url = new URL(urlString);
|
|
2716
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
2717
|
+
return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
|
|
2718
|
+
}
|
|
2719
|
+
if (url.username || url.password) {
|
|
2720
|
+
return { isValid: false, error: "URLs with credentials not allowed" };
|
|
2721
|
+
}
|
|
2722
|
+
const hostname = url.hostname.toLowerCase();
|
|
2723
|
+
if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
2724
|
+
return { isValid: false, error: "Access to internal addresses not allowed" };
|
|
2725
|
+
}
|
|
2726
|
+
return { isValid: true, sanitizedUrl: url.toString() };
|
|
2727
|
+
} catch {
|
|
2728
|
+
return { isValid: false, error: "Invalid URL format" };
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2559
2732
|
// src/tools/distribution.ts
|
|
2560
2733
|
init_supabase();
|
|
2561
2734
|
|
|
@@ -2717,16 +2890,42 @@ function asEnvelope2(data) {
|
|
|
2717
2890
|
data
|
|
2718
2891
|
};
|
|
2719
2892
|
}
|
|
2893
|
+
function isAlreadyR2Signed(url) {
|
|
2894
|
+
try {
|
|
2895
|
+
const u = new URL(url);
|
|
2896
|
+
return u.searchParams.has("X-Amz-Signature");
|
|
2897
|
+
} catch {
|
|
2898
|
+
return false;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
async function rehostExternalUrl(mediaUrl, projectId) {
|
|
2902
|
+
if (isAlreadyR2Signed(mediaUrl)) {
|
|
2903
|
+
return { signedUrl: mediaUrl, r2Key: "" };
|
|
2904
|
+
}
|
|
2905
|
+
const ssrf = quickSSRFCheck(mediaUrl);
|
|
2906
|
+
if (!ssrf.isValid) {
|
|
2907
|
+
return { error: ssrf.error ?? "URL rejected by SSRF check" };
|
|
2908
|
+
}
|
|
2909
|
+
const { data, error } = await callEdgeFunction(
|
|
2910
|
+
"upload-to-r2",
|
|
2911
|
+
{ url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
|
|
2912
|
+
{ timeoutMs: 6e4 }
|
|
2913
|
+
);
|
|
2914
|
+
if (error || !data?.key || !data?.url) {
|
|
2915
|
+
return { error: error ?? "upload-to-r2 returned no key" };
|
|
2916
|
+
}
|
|
2917
|
+
return { signedUrl: data.url, r2Key: data.key };
|
|
2918
|
+
}
|
|
2720
2919
|
function registerDistributionTools(server) {
|
|
2721
2920
|
server.tool(
|
|
2722
2921
|
"schedule_post",
|
|
2723
2922
|
'Publish or schedule a post to connected social platforms. Check list_connected_accounts first to verify active OAuth for each target platform. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
|
|
2724
2923
|
{
|
|
2725
2924
|
media_url: z3.string().optional().describe(
|
|
2726
|
-
"URL of the media file to post.
|
|
2925
|
+
"URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
|
|
2727
2926
|
),
|
|
2728
2927
|
media_urls: z3.array(z3.string()).optional().describe(
|
|
2729
|
-
"Array of 2-10 image URLs for carousel posts.
|
|
2928
|
+
"Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
|
|
2730
2929
|
),
|
|
2731
2930
|
r2_key: z3.string().optional().describe(
|
|
2732
2931
|
"R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
|
|
@@ -2815,7 +3014,10 @@ function registerDistributionTools(server) {
|
|
|
2815
3014
|
),
|
|
2816
3015
|
project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
|
|
2817
3016
|
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
|
|
2818
|
-
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
|
|
3017
|
+
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
|
|
3018
|
+
auto_rehost: z3.boolean().optional().describe(
|
|
3019
|
+
"Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
|
|
3020
|
+
)
|
|
2819
3021
|
},
|
|
2820
3022
|
async ({
|
|
2821
3023
|
media_url,
|
|
@@ -2829,6 +3031,7 @@ function registerDistributionTools(server) {
|
|
|
2829
3031
|
project_id,
|
|
2830
3032
|
response_format,
|
|
2831
3033
|
attribution,
|
|
3034
|
+
auto_rehost,
|
|
2832
3035
|
r2_key,
|
|
2833
3036
|
r2_keys,
|
|
2834
3037
|
job_id,
|
|
@@ -2940,12 +3143,54 @@ function registerDistributionTools(server) {
|
|
|
2940
3143
|
}
|
|
2941
3144
|
resolvedMediaUrls = resolved;
|
|
2942
3145
|
}
|
|
3146
|
+
const shouldRehost = auto_rehost !== false;
|
|
3147
|
+
if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
|
|
3148
|
+
const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
|
|
3149
|
+
if ("error" in rehost) {
|
|
3150
|
+
return {
|
|
3151
|
+
content: [
|
|
3152
|
+
{
|
|
3153
|
+
type: "text",
|
|
3154
|
+
text: `Failed to persist media URL into R2: ${rehost.error}. Try upload_media first and pass r2_key instead, or set auto_rehost=false if you're sure the URL is publicly durable and every target platform accepts URL ingest.`
|
|
3155
|
+
}
|
|
3156
|
+
],
|
|
3157
|
+
isError: true
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
resolvedMediaUrl = rehost.signedUrl;
|
|
3161
|
+
}
|
|
3162
|
+
if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
|
|
3163
|
+
const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
|
|
3164
|
+
if (needsRehost.some(Boolean)) {
|
|
3165
|
+
const rehosted = await Promise.all(
|
|
3166
|
+
resolvedMediaUrls.map(
|
|
3167
|
+
(u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
|
|
3168
|
+
)
|
|
3169
|
+
);
|
|
3170
|
+
const failIdx = rehosted.findIndex((r) => "error" in r);
|
|
3171
|
+
if (failIdx !== -1) {
|
|
3172
|
+
const failed = rehosted[failIdx];
|
|
3173
|
+
return {
|
|
3174
|
+
content: [
|
|
3175
|
+
{
|
|
3176
|
+
type: "text",
|
|
3177
|
+
text: `Failed to persist media_urls[${failIdx}] into R2: ${failed.error}. Try upload_media first and pass r2_keys instead, or set auto_rehost=false.`
|
|
3178
|
+
}
|
|
3179
|
+
],
|
|
3180
|
+
isError: true
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
resolvedMediaUrls = rehosted.map(
|
|
3184
|
+
(r) => r.signedUrl
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
2943
3188
|
} catch (resolveErr) {
|
|
2944
3189
|
return {
|
|
2945
3190
|
content: [
|
|
2946
3191
|
{
|
|
2947
3192
|
type: "text",
|
|
2948
|
-
text: `Failed to resolve media: ${
|
|
3193
|
+
text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
|
|
2949
3194
|
}
|
|
2950
3195
|
],
|
|
2951
3196
|
isError: true
|
|
@@ -3310,7 +3555,7 @@ Created with Social Neuron`;
|
|
|
3310
3555
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
|
|
3311
3556
|
} catch (err) {
|
|
3312
3557
|
const durationMs = Date.now() - startedAt;
|
|
3313
|
-
const message =
|
|
3558
|
+
const message = sanitizeError(err);
|
|
3314
3559
|
logMcpToolInvocation({
|
|
3315
3560
|
toolName: "find_next_slots",
|
|
3316
3561
|
status: "error",
|
|
@@ -3830,7 +4075,7 @@ Created with Social Neuron`;
|
|
|
3830
4075
|
};
|
|
3831
4076
|
} catch (err) {
|
|
3832
4077
|
const durationMs = Date.now() - startedAt;
|
|
3833
|
-
const message =
|
|
4078
|
+
const message = sanitizeError(err);
|
|
3834
4079
|
logMcpToolInvocation({
|
|
3835
4080
|
toolName: "schedule_content_plan",
|
|
3836
4081
|
status: "error",
|
|
@@ -3852,6 +4097,19 @@ import { readFile } from "node:fs/promises";
|
|
|
3852
4097
|
import { basename, extname } from "node:path";
|
|
3853
4098
|
init_supabase();
|
|
3854
4099
|
var MAX_BASE64_SIZE = 10 * 1024 * 1024;
|
|
4100
|
+
var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
4101
|
+
"image/png",
|
|
4102
|
+
"image/jpeg",
|
|
4103
|
+
"image/gif",
|
|
4104
|
+
"image/webp",
|
|
4105
|
+
"image/svg+xml",
|
|
4106
|
+
"video/mp4",
|
|
4107
|
+
"video/quicktime",
|
|
4108
|
+
"video/x-msvideo",
|
|
4109
|
+
"video/webm"
|
|
4110
|
+
]);
|
|
4111
|
+
var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
4112
|
+
var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
|
|
3855
4113
|
function maskR2Key(key) {
|
|
3856
4114
|
const segments = key.split("/");
|
|
3857
4115
|
return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
|
|
@@ -3872,21 +4130,35 @@ function inferContentType(filePath) {
|
|
|
3872
4130
|
};
|
|
3873
4131
|
return map[ext] || "application/octet-stream";
|
|
3874
4132
|
}
|
|
4133
|
+
function approxBase64Size(raw) {
|
|
4134
|
+
const len = raw.length;
|
|
4135
|
+
if (len === 0) return 0;
|
|
4136
|
+
let padding = 0;
|
|
4137
|
+
if (raw.endsWith("==")) padding = 2;
|
|
4138
|
+
else if (raw.endsWith("=")) padding = 1;
|
|
4139
|
+
return Math.floor(len * 3 / 4) - padding;
|
|
4140
|
+
}
|
|
3875
4141
|
function registerMediaTools(server) {
|
|
3876
4142
|
server.tool(
|
|
3877
4143
|
"upload_media",
|
|
3878
|
-
"Upload
|
|
4144
|
+
"Upload media to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Three input modes: (1) local file path (stdio mode only), (2) public URL fetched by the server, (3) inline base64 via file_data \u2014 use this from Claude Desktop, Claude Web, or any remote agent that cannot hand the server a filesystem path. Base64 uploads are capped at 10MB decoded; larger files still need stdio + presigned PUT.",
|
|
3879
4145
|
{
|
|
3880
|
-
source: z4.string().describe(
|
|
3881
|
-
'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg").
|
|
4146
|
+
source: z4.string().optional().describe(
|
|
4147
|
+
'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg"). Leave empty when passing file_data instead.'
|
|
4148
|
+
),
|
|
4149
|
+
file_data: z4.string().optional().describe(
|
|
4150
|
+
'Base64-encoded file bytes, with or without a "data:<mime>;base64," prefix. Use this from remote agents (Claude Web/Desktop) that cannot provide a filesystem path. Max 10MB decoded.'
|
|
4151
|
+
),
|
|
4152
|
+
file_name: z4.string().optional().describe(
|
|
4153
|
+
'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
|
|
3882
4154
|
),
|
|
3883
4155
|
content_type: z4.string().optional().describe(
|
|
3884
|
-
'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension
|
|
4156
|
+
'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension for paths/URLs, or from the data: prefix on file_data. Required when passing raw base64 with no prefix.'
|
|
3885
4157
|
),
|
|
3886
4158
|
project_id: z4.string().optional().describe("Project ID for R2 path organization."),
|
|
3887
4159
|
response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
3888
4160
|
},
|
|
3889
|
-
async ({ source, content_type, project_id, response_format }) => {
|
|
4161
|
+
async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
|
|
3890
4162
|
const format = response_format ?? "text";
|
|
3891
4163
|
const startedAt = Date.now();
|
|
3892
4164
|
const userId = await getDefaultUserId();
|
|
@@ -3908,46 +4180,187 @@ function registerMediaTools(server) {
|
|
|
3908
4180
|
isError: true
|
|
3909
4181
|
};
|
|
3910
4182
|
}
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
4183
|
+
if (!source && !file_data) {
|
|
4184
|
+
return {
|
|
4185
|
+
content: [
|
|
4186
|
+
{
|
|
4187
|
+
type: "text",
|
|
4188
|
+
text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
|
|
4189
|
+
}
|
|
4190
|
+
],
|
|
4191
|
+
isError: true
|
|
4192
|
+
};
|
|
4193
|
+
}
|
|
4194
|
+
if (file_data) {
|
|
4195
|
+
let raw = file_data;
|
|
4196
|
+
let detectedType;
|
|
4197
|
+
const prefixMatch = raw.match(DATA_URI_PREFIX);
|
|
4198
|
+
if (prefixMatch) {
|
|
4199
|
+
detectedType = prefixMatch[1].trim().toLowerCase();
|
|
4200
|
+
raw = raw.slice(prefixMatch[0].length);
|
|
4201
|
+
}
|
|
4202
|
+
const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
|
|
4203
|
+
if (!ct) {
|
|
4204
|
+
return {
|
|
4205
|
+
content: [
|
|
4206
|
+
{
|
|
4207
|
+
type: "text",
|
|
4208
|
+
text: "content_type is required when file_data has no data: prefix."
|
|
4209
|
+
}
|
|
4210
|
+
],
|
|
4211
|
+
isError: true
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
|
|
4215
|
+
return {
|
|
4216
|
+
content: [
|
|
4217
|
+
{
|
|
4218
|
+
type: "text",
|
|
4219
|
+
text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
|
|
4220
|
+
}
|
|
4221
|
+
],
|
|
4222
|
+
isError: true
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4225
|
+
const stripped = raw.replace(/\s+/g, "");
|
|
4226
|
+
if (!BASE64_CHARS.test(stripped)) {
|
|
4227
|
+
return {
|
|
4228
|
+
content: [
|
|
4229
|
+
{
|
|
4230
|
+
type: "text",
|
|
4231
|
+
text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
|
|
4232
|
+
}
|
|
4233
|
+
],
|
|
4234
|
+
isError: true
|
|
4235
|
+
};
|
|
4236
|
+
}
|
|
4237
|
+
const approxSize = approxBase64Size(stripped);
|
|
4238
|
+
if (approxSize > MAX_BASE64_SIZE) {
|
|
4239
|
+
return {
|
|
4240
|
+
content: [
|
|
4241
|
+
{
|
|
4242
|
+
type: "text",
|
|
4243
|
+
text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). For larger files, run the stdio MCP server locally and pass a file path so the server can use presigned PUT upload.`
|
|
4244
|
+
}
|
|
4245
|
+
],
|
|
4246
|
+
isError: true
|
|
4247
|
+
};
|
|
4248
|
+
}
|
|
4249
|
+
const safeName = basename(file_name ?? "upload");
|
|
4250
|
+
const uploadBody2 = {
|
|
4251
|
+
fileData: `data:${ct};base64,${stripped}`,
|
|
3918
4252
|
contentType: ct,
|
|
3919
|
-
fileName:
|
|
4253
|
+
fileName: safeName,
|
|
3920
4254
|
projectId: project_id
|
|
3921
4255
|
};
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
try {
|
|
3925
|
-
fileBuffer = await readFile(source);
|
|
3926
|
-
} catch (err) {
|
|
4256
|
+
const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
|
|
4257
|
+
if (error2) {
|
|
3927
4258
|
await logMcpToolInvocation({
|
|
3928
4259
|
toolName: "upload_media",
|
|
3929
4260
|
status: "error",
|
|
3930
4261
|
durationMs: Date.now() - startedAt,
|
|
3931
|
-
details: { error:
|
|
4262
|
+
details: { error: error2, source: "base64", contentType: ct, size: approxSize }
|
|
3932
4263
|
});
|
|
3933
4264
|
return {
|
|
3934
|
-
content: [
|
|
3935
|
-
{
|
|
3936
|
-
type: "text",
|
|
3937
|
-
text: `File not found or not readable: ${source}`
|
|
3938
|
-
}
|
|
3939
|
-
],
|
|
4265
|
+
content: [{ type: "text", text: `Upload failed: ${error2}` }],
|
|
3940
4266
|
isError: true
|
|
3941
4267
|
};
|
|
3942
4268
|
}
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
4269
|
+
if (!data2?.key) {
|
|
4270
|
+
return {
|
|
4271
|
+
content: [{ type: "text", text: "Upload returned no R2 key." }],
|
|
4272
|
+
isError: true
|
|
4273
|
+
};
|
|
4274
|
+
}
|
|
4275
|
+
await logMcpToolInvocation({
|
|
4276
|
+
toolName: "upload_media",
|
|
4277
|
+
status: "success",
|
|
4278
|
+
durationMs: Date.now() - startedAt,
|
|
4279
|
+
details: {
|
|
4280
|
+
source: "base64",
|
|
4281
|
+
r2Key: data2.key,
|
|
4282
|
+
size: data2.size,
|
|
4283
|
+
contentType: data2.contentType
|
|
4284
|
+
}
|
|
4285
|
+
});
|
|
4286
|
+
if (format === "json") {
|
|
4287
|
+
return {
|
|
4288
|
+
content: [
|
|
4289
|
+
{
|
|
4290
|
+
type: "text",
|
|
4291
|
+
text: JSON.stringify(
|
|
4292
|
+
{
|
|
4293
|
+
r2_key: data2.key,
|
|
4294
|
+
signed_url: data2.url,
|
|
4295
|
+
size: data2.size,
|
|
4296
|
+
content_type: data2.contentType
|
|
4297
|
+
},
|
|
4298
|
+
null,
|
|
4299
|
+
2
|
|
4300
|
+
)
|
|
4301
|
+
}
|
|
4302
|
+
],
|
|
4303
|
+
isError: false
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
4306
|
+
return {
|
|
4307
|
+
content: [
|
|
4308
|
+
{
|
|
4309
|
+
type: "text",
|
|
4310
|
+
text: [
|
|
4311
|
+
"Media uploaded successfully.",
|
|
4312
|
+
`Media key: ${maskR2Key(data2.key)}`,
|
|
4313
|
+
`Signed URL: ${data2.url}`,
|
|
4314
|
+
`Size: ${(data2.size / 1024).toFixed(0)}KB`,
|
|
4315
|
+
`Type: ${data2.contentType}`,
|
|
4316
|
+
"",
|
|
4317
|
+
"Use job_id or response_format=json with schedule_post to post to any platform."
|
|
4318
|
+
].join("\n")
|
|
4319
|
+
}
|
|
4320
|
+
],
|
|
4321
|
+
isError: false
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
const src = source;
|
|
4325
|
+
const isUrl = src.startsWith("http://") || src.startsWith("https://");
|
|
4326
|
+
let uploadBody;
|
|
4327
|
+
if (isUrl) {
|
|
4328
|
+
const ct = content_type || inferContentType(src);
|
|
4329
|
+
uploadBody = {
|
|
4330
|
+
url: src,
|
|
4331
|
+
contentType: ct,
|
|
4332
|
+
fileName: basename(file_name ?? new URL(src).pathname) || "upload",
|
|
4333
|
+
projectId: project_id
|
|
4334
|
+
};
|
|
4335
|
+
} else {
|
|
4336
|
+
let fileBuffer;
|
|
4337
|
+
try {
|
|
4338
|
+
fileBuffer = await readFile(src);
|
|
4339
|
+
} catch {
|
|
4340
|
+
await logMcpToolInvocation({
|
|
4341
|
+
toolName: "upload_media",
|
|
4342
|
+
status: "error",
|
|
4343
|
+
durationMs: Date.now() - startedAt,
|
|
4344
|
+
details: { error: "File not found", source: "local" }
|
|
4345
|
+
});
|
|
4346
|
+
return {
|
|
4347
|
+
content: [
|
|
4348
|
+
{
|
|
4349
|
+
type: "text",
|
|
4350
|
+
text: `File not found or not readable: ${src}. Remote agents (Claude Web/Desktop) cannot see the MCP server's filesystem \u2014 pass the bytes via the \`file_data\` parameter (base64, up to 10MB) instead.`
|
|
4351
|
+
}
|
|
4352
|
+
],
|
|
4353
|
+
isError: true
|
|
4354
|
+
};
|
|
4355
|
+
}
|
|
4356
|
+
const ct = content_type || inferContentType(src);
|
|
4357
|
+
if (fileBuffer.length > MAX_BASE64_SIZE) {
|
|
4358
|
+
const { data: putData, error: putError } = await callEdgeFunction(
|
|
4359
|
+
"get-signed-url",
|
|
3947
4360
|
{
|
|
3948
4361
|
operation: "put",
|
|
3949
4362
|
contentType: ct,
|
|
3950
|
-
filename: basename(
|
|
4363
|
+
filename: basename(file_name ?? src),
|
|
3951
4364
|
projectId: project_id
|
|
3952
4365
|
},
|
|
3953
4366
|
{ timeoutMs: 1e4 }
|
|
@@ -4049,7 +4462,7 @@ function registerMediaTools(server) {
|
|
|
4049
4462
|
uploadBody = {
|
|
4050
4463
|
fileData: base64,
|
|
4051
4464
|
contentType: ct,
|
|
4052
|
-
fileName: basename(
|
|
4465
|
+
fileName: basename(file_name ?? src),
|
|
4053
4466
|
projectId: project_id
|
|
4054
4467
|
};
|
|
4055
4468
|
}
|
|
@@ -4438,155 +4851,6 @@ function formatAnalytics(summary, days, format) {
|
|
|
4438
4851
|
// src/tools/brand.ts
|
|
4439
4852
|
import { z as z6 } from "zod";
|
|
4440
4853
|
init_supabase();
|
|
4441
|
-
|
|
4442
|
-
// src/lib/ssrf.ts
|
|
4443
|
-
var BLOCKED_IP_PATTERNS = [
|
|
4444
|
-
// IPv4 localhost/loopback
|
|
4445
|
-
/^127\./,
|
|
4446
|
-
/^0\./,
|
|
4447
|
-
// IPv4 private ranges (RFC 1918)
|
|
4448
|
-
/^10\./,
|
|
4449
|
-
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
4450
|
-
/^192\.168\./,
|
|
4451
|
-
// IPv4 link-local
|
|
4452
|
-
/^169\.254\./,
|
|
4453
|
-
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
4454
|
-
/^169\.254\.169\.254$/,
|
|
4455
|
-
// IPv4 broadcast
|
|
4456
|
-
/^255\./,
|
|
4457
|
-
// Shared address space (RFC 6598)
|
|
4458
|
-
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
4459
|
-
];
|
|
4460
|
-
var BLOCKED_IPV6_PATTERNS = [
|
|
4461
|
-
/^::1$/i,
|
|
4462
|
-
// loopback
|
|
4463
|
-
/^::$/i,
|
|
4464
|
-
// unspecified
|
|
4465
|
-
/^fe[89ab][0-9a-f]:/i,
|
|
4466
|
-
// link-local fe80::/10
|
|
4467
|
-
/^fc[0-9a-f]:/i,
|
|
4468
|
-
// unique local fc00::/7
|
|
4469
|
-
/^fd[0-9a-f]:/i,
|
|
4470
|
-
// unique local fc00::/7
|
|
4471
|
-
/^::ffff:127\./i,
|
|
4472
|
-
// IPv4-mapped localhost
|
|
4473
|
-
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
4474
|
-
// IPv4-mapped private
|
|
4475
|
-
];
|
|
4476
|
-
var BLOCKED_HOSTNAMES = [
|
|
4477
|
-
"localhost",
|
|
4478
|
-
"localhost.localdomain",
|
|
4479
|
-
"local",
|
|
4480
|
-
"127.0.0.1",
|
|
4481
|
-
"0.0.0.0",
|
|
4482
|
-
"[::1]",
|
|
4483
|
-
"[::ffff:127.0.0.1]",
|
|
4484
|
-
// Cloud metadata endpoints
|
|
4485
|
-
"metadata.google.internal",
|
|
4486
|
-
"metadata.goog",
|
|
4487
|
-
"instance-data",
|
|
4488
|
-
"instance-data.ec2.internal"
|
|
4489
|
-
];
|
|
4490
|
-
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
4491
|
-
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
4492
|
-
function isBlockedIP(ip) {
|
|
4493
|
-
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
4494
|
-
if (normalized.includes(":")) {
|
|
4495
|
-
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
4496
|
-
}
|
|
4497
|
-
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
4498
|
-
}
|
|
4499
|
-
function isBlockedHostname(hostname) {
|
|
4500
|
-
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
4501
|
-
}
|
|
4502
|
-
function isIPAddress(hostname) {
|
|
4503
|
-
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
4504
|
-
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
4505
|
-
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
4506
|
-
}
|
|
4507
|
-
async function validateUrlForSSRF(urlString) {
|
|
4508
|
-
try {
|
|
4509
|
-
const url = new URL(urlString);
|
|
4510
|
-
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
4511
|
-
return {
|
|
4512
|
-
isValid: false,
|
|
4513
|
-
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
4514
|
-
};
|
|
4515
|
-
}
|
|
4516
|
-
if (url.username || url.password) {
|
|
4517
|
-
return {
|
|
4518
|
-
isValid: false,
|
|
4519
|
-
error: "URLs with embedded credentials are not allowed."
|
|
4520
|
-
};
|
|
4521
|
-
}
|
|
4522
|
-
const hostname = url.hostname.toLowerCase();
|
|
4523
|
-
if (isBlockedHostname(hostname)) {
|
|
4524
|
-
return {
|
|
4525
|
-
isValid: false,
|
|
4526
|
-
error: "Access to internal/localhost addresses is not allowed."
|
|
4527
|
-
};
|
|
4528
|
-
}
|
|
4529
|
-
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
4530
|
-
return {
|
|
4531
|
-
isValid: false,
|
|
4532
|
-
error: "Access to private/internal IP addresses is not allowed."
|
|
4533
|
-
};
|
|
4534
|
-
}
|
|
4535
|
-
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
4536
|
-
if (BLOCKED_PORTS.includes(port)) {
|
|
4537
|
-
return {
|
|
4538
|
-
isValid: false,
|
|
4539
|
-
error: `Access to port ${port} is not allowed.`
|
|
4540
|
-
};
|
|
4541
|
-
}
|
|
4542
|
-
let resolvedIP;
|
|
4543
|
-
if (!isIPAddress(hostname)) {
|
|
4544
|
-
try {
|
|
4545
|
-
const dns = await import("node:dns");
|
|
4546
|
-
const resolver = new dns.promises.Resolver();
|
|
4547
|
-
const resolvedIPs = [];
|
|
4548
|
-
try {
|
|
4549
|
-
const aRecords = await resolver.resolve4(hostname);
|
|
4550
|
-
resolvedIPs.push(...aRecords);
|
|
4551
|
-
} catch {
|
|
4552
|
-
}
|
|
4553
|
-
try {
|
|
4554
|
-
const aaaaRecords = await resolver.resolve6(hostname);
|
|
4555
|
-
resolvedIPs.push(...aaaaRecords);
|
|
4556
|
-
} catch {
|
|
4557
|
-
}
|
|
4558
|
-
if (resolvedIPs.length === 0) {
|
|
4559
|
-
return {
|
|
4560
|
-
isValid: false,
|
|
4561
|
-
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
4562
|
-
};
|
|
4563
|
-
}
|
|
4564
|
-
for (const ip of resolvedIPs) {
|
|
4565
|
-
if (isBlockedIP(ip)) {
|
|
4566
|
-
return {
|
|
4567
|
-
isValid: false,
|
|
4568
|
-
error: "Hostname resolves to a private/internal IP address."
|
|
4569
|
-
};
|
|
4570
|
-
}
|
|
4571
|
-
}
|
|
4572
|
-
resolvedIP = resolvedIPs[0];
|
|
4573
|
-
} catch {
|
|
4574
|
-
return {
|
|
4575
|
-
isValid: false,
|
|
4576
|
-
error: "DNS resolution failed. Cannot verify hostname safety."
|
|
4577
|
-
};
|
|
4578
|
-
}
|
|
4579
|
-
}
|
|
4580
|
-
return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
|
|
4581
|
-
} catch (error) {
|
|
4582
|
-
return {
|
|
4583
|
-
isValid: false,
|
|
4584
|
-
error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
4585
|
-
};
|
|
4586
|
-
}
|
|
4587
|
-
}
|
|
4588
|
-
|
|
4589
|
-
// src/tools/brand.ts
|
|
4590
4854
|
function asEnvelope4(data) {
|
|
4591
4855
|
return {
|
|
4592
4856
|
_meta: {
|
|
@@ -5109,7 +5373,7 @@ function registerScreenshotTools(server) {
|
|
|
5109
5373
|
};
|
|
5110
5374
|
} catch (err) {
|
|
5111
5375
|
await closeBrowser();
|
|
5112
|
-
const message =
|
|
5376
|
+
const message = sanitizeError(err);
|
|
5113
5377
|
await logMcpToolInvocation({
|
|
5114
5378
|
toolName: "capture_app_page",
|
|
5115
5379
|
status: "error",
|
|
@@ -5265,7 +5529,7 @@ function registerScreenshotTools(server) {
|
|
|
5265
5529
|
};
|
|
5266
5530
|
} catch (err) {
|
|
5267
5531
|
await closeBrowser();
|
|
5268
|
-
const message =
|
|
5532
|
+
const message = sanitizeError(err);
|
|
5269
5533
|
await logMcpToolInvocation({
|
|
5270
5534
|
toolName: "capture_screenshot",
|
|
5271
5535
|
status: "error",
|
|
@@ -5558,7 +5822,7 @@ function registerRemotionTools(server) {
|
|
|
5558
5822
|
]
|
|
5559
5823
|
};
|
|
5560
5824
|
} catch (err) {
|
|
5561
|
-
const message =
|
|
5825
|
+
const message = sanitizeError(err);
|
|
5562
5826
|
await logMcpToolInvocation({
|
|
5563
5827
|
toolName: "render_demo_video",
|
|
5564
5828
|
status: "error",
|
|
@@ -5686,7 +5950,7 @@ function registerRemotionTools(server) {
|
|
|
5686
5950
|
]
|
|
5687
5951
|
};
|
|
5688
5952
|
} catch (err) {
|
|
5689
|
-
const message =
|
|
5953
|
+
const message = sanitizeError(err);
|
|
5690
5954
|
await logMcpToolInvocation({
|
|
5691
5955
|
toolName: "render_template_video",
|
|
5692
5956
|
status: "error",
|
|
@@ -7130,44 +7394,310 @@ Active: ${is_active}`
|
|
|
7130
7394
|
);
|
|
7131
7395
|
}
|
|
7132
7396
|
|
|
7133
|
-
// src/tools/
|
|
7397
|
+
// src/tools/recipes.ts
|
|
7134
7398
|
import { z as z17 } from "zod";
|
|
7135
|
-
init_supabase();
|
|
7136
7399
|
function asEnvelope13(data) {
|
|
7137
|
-
return {
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7400
|
+
return {
|
|
7401
|
+
_meta: {
|
|
7402
|
+
version: MCP_VERSION,
|
|
7403
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
7404
|
+
},
|
|
7405
|
+
data
|
|
7406
|
+
};
|
|
7143
7407
|
}
|
|
7144
|
-
function
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7408
|
+
function registerRecipeTools(server) {
|
|
7409
|
+
server.tool(
|
|
7410
|
+
"list_recipes",
|
|
7411
|
+
'List available recipe templates. Recipes are pre-built multi-step workflows like "Weekly Instagram Calendar" or "Product Launch Sequence" that automate common content operations. Use this to discover what recipes are available before running one.',
|
|
7412
|
+
{
|
|
7413
|
+
category: z17.enum([
|
|
7414
|
+
"content_creation",
|
|
7415
|
+
"distribution",
|
|
7416
|
+
"repurposing",
|
|
7417
|
+
"analytics",
|
|
7418
|
+
"engagement",
|
|
7419
|
+
"general"
|
|
7420
|
+
]).optional().describe("Filter by category. Omit to list all."),
|
|
7421
|
+
featured_only: z17.boolean().optional().describe("If true, only return featured recipes. Defaults to false."),
|
|
7422
|
+
response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
7423
|
+
},
|
|
7424
|
+
async ({ category, featured_only, response_format }) => {
|
|
7425
|
+
const format = response_format ?? "text";
|
|
7426
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
7427
|
+
action: "list-recipes",
|
|
7428
|
+
category: category ?? null,
|
|
7429
|
+
featured_only: featured_only ?? false
|
|
7430
|
+
});
|
|
7431
|
+
if (efError) {
|
|
7432
|
+
return {
|
|
7433
|
+
content: [
|
|
7434
|
+
{
|
|
7435
|
+
type: "text",
|
|
7436
|
+
text: `Error fetching recipes: ${efError}`
|
|
7437
|
+
}
|
|
7438
|
+
],
|
|
7439
|
+
isError: true
|
|
7440
|
+
};
|
|
7441
|
+
}
|
|
7442
|
+
const recipes = result?.recipes ?? [];
|
|
7443
|
+
if (format === "json") {
|
|
7444
|
+
return {
|
|
7445
|
+
content: [
|
|
7446
|
+
{
|
|
7447
|
+
type: "text",
|
|
7448
|
+
text: JSON.stringify(asEnvelope13(recipes))
|
|
7449
|
+
}
|
|
7450
|
+
]
|
|
7451
|
+
};
|
|
7452
|
+
}
|
|
7453
|
+
if (recipes.length === 0) {
|
|
7454
|
+
return {
|
|
7455
|
+
content: [
|
|
7456
|
+
{
|
|
7457
|
+
type: "text",
|
|
7458
|
+
text: "No recipes found. Recipes are pre-built automation templates \u2014 check back after setup."
|
|
7459
|
+
}
|
|
7460
|
+
]
|
|
7461
|
+
};
|
|
7462
|
+
}
|
|
7463
|
+
const lines = recipes.map(
|
|
7464
|
+
(r) => `**${r.name}** (${r.slug})
|
|
7465
|
+
${r.description}
|
|
7466
|
+
Category: ${r.category} | Credits: ~${r.estimated_credits} | Steps: ${r.steps.length}${r.is_featured ? " | \u2B50 Featured" : ""}
|
|
7467
|
+
Inputs: ${r.inputs_schema.map((i) => `${i.label}${i.required ? "*" : ""}`).join(", ")}`
|
|
7468
|
+
);
|
|
7469
|
+
return {
|
|
7470
|
+
content: [
|
|
7471
|
+
{
|
|
7472
|
+
type: "text",
|
|
7473
|
+
text: `## Available Recipes (${recipes.length})
|
|
7474
|
+
|
|
7475
|
+
${lines.join("\n\n")}`
|
|
7476
|
+
}
|
|
7477
|
+
]
|
|
7478
|
+
};
|
|
7479
|
+
}
|
|
7480
|
+
);
|
|
7481
|
+
server.tool(
|
|
7482
|
+
"get_recipe_details",
|
|
7483
|
+
"Get full details of a recipe template including all steps, input schema, and estimated costs. Use this before execute_recipe to understand what inputs are required.",
|
|
7484
|
+
{
|
|
7485
|
+
slug: z17.string().describe('Recipe slug (e.g., "weekly-instagram-calendar")'),
|
|
7486
|
+
response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
7487
|
+
},
|
|
7488
|
+
async ({ slug, response_format }) => {
|
|
7489
|
+
const format = response_format ?? "text";
|
|
7490
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
7491
|
+
action: "get-recipe-details",
|
|
7492
|
+
slug
|
|
7493
|
+
});
|
|
7494
|
+
if (efError) {
|
|
7495
|
+
return {
|
|
7496
|
+
content: [{ type: "text", text: `Error: ${efError}` }],
|
|
7497
|
+
isError: true
|
|
7498
|
+
};
|
|
7499
|
+
}
|
|
7500
|
+
const recipe = result?.recipe;
|
|
7501
|
+
if (!recipe) {
|
|
7502
|
+
return {
|
|
7503
|
+
content: [
|
|
7504
|
+
{
|
|
7505
|
+
type: "text",
|
|
7506
|
+
text: `Recipe "${slug}" not found. Use list_recipes to see available recipes.`
|
|
7507
|
+
}
|
|
7508
|
+
],
|
|
7509
|
+
isError: true
|
|
7510
|
+
};
|
|
7511
|
+
}
|
|
7512
|
+
if (format === "json") {
|
|
7513
|
+
return {
|
|
7514
|
+
content: [
|
|
7515
|
+
{
|
|
7516
|
+
type: "text",
|
|
7517
|
+
text: JSON.stringify(asEnvelope13(recipe))
|
|
7518
|
+
}
|
|
7519
|
+
]
|
|
7520
|
+
};
|
|
7521
|
+
}
|
|
7522
|
+
const stepsText = recipe.steps.map((s, i) => ` ${i + 1}. **${s.name}** (${s.type})`).join("\n");
|
|
7523
|
+
const inputsText = recipe.inputs_schema.map(
|
|
7524
|
+
(i) => ` - **${i.label}**${i.required ? " (required)" : ""}: ${i.type}${i.placeholder ? ` \u2014 e.g., "${i.placeholder}"` : ""}`
|
|
7525
|
+
).join("\n");
|
|
7526
|
+
return {
|
|
7527
|
+
content: [
|
|
7528
|
+
{
|
|
7529
|
+
type: "text",
|
|
7530
|
+
text: [
|
|
7531
|
+
`## ${recipe.name}`,
|
|
7532
|
+
recipe.description,
|
|
7533
|
+
"",
|
|
7534
|
+
`**Category:** ${recipe.category}`,
|
|
7535
|
+
`**Estimated credits:** ~${recipe.estimated_credits}`,
|
|
7536
|
+
`**Estimated time:** ~${Math.round(recipe.estimated_duration_seconds / 60)} minutes`,
|
|
7537
|
+
"",
|
|
7538
|
+
"### Steps",
|
|
7539
|
+
stepsText,
|
|
7540
|
+
"",
|
|
7541
|
+
"### Required Inputs",
|
|
7542
|
+
inputsText
|
|
7543
|
+
].join("\n")
|
|
7544
|
+
}
|
|
7545
|
+
]
|
|
7546
|
+
};
|
|
7547
|
+
}
|
|
7548
|
+
);
|
|
7549
|
+
server.tool(
|
|
7550
|
+
"execute_recipe",
|
|
7551
|
+
"Execute a recipe template with the provided inputs. This creates a recipe run that processes each step sequentially. Long-running recipes will return a run_id you can check with get_recipe_run_status.",
|
|
7552
|
+
{
|
|
7553
|
+
slug: z17.string().describe('Recipe slug (e.g., "weekly-instagram-calendar")'),
|
|
7554
|
+
inputs: z17.record(z17.unknown()).describe(
|
|
7555
|
+
"Input values matching the recipe input schema. Use get_recipe_details to see required inputs."
|
|
7556
|
+
),
|
|
7557
|
+
response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
7558
|
+
},
|
|
7559
|
+
async ({ slug, inputs, response_format }) => {
|
|
7560
|
+
const format = response_format ?? "text";
|
|
7561
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
7562
|
+
action: "execute-recipe",
|
|
7563
|
+
slug,
|
|
7564
|
+
inputs
|
|
7565
|
+
});
|
|
7566
|
+
if (efError) {
|
|
7567
|
+
return {
|
|
7568
|
+
content: [{ type: "text", text: `Error: ${efError}` }],
|
|
7569
|
+
isError: true
|
|
7570
|
+
};
|
|
7571
|
+
}
|
|
7572
|
+
if (format === "json") {
|
|
7573
|
+
return {
|
|
7574
|
+
content: [
|
|
7575
|
+
{
|
|
7576
|
+
type: "text",
|
|
7577
|
+
text: JSON.stringify(asEnvelope13(result))
|
|
7578
|
+
}
|
|
7579
|
+
]
|
|
7580
|
+
};
|
|
7581
|
+
}
|
|
7582
|
+
return {
|
|
7583
|
+
content: [
|
|
7584
|
+
{
|
|
7585
|
+
type: "text",
|
|
7586
|
+
text: `Recipe "${slug}" started.
|
|
7587
|
+
|
|
7588
|
+
**Run ID:** ${result?.run_id}
|
|
7589
|
+
**Status:** ${result?.status}
|
|
7590
|
+
|
|
7591
|
+
${result?.message || "Use get_recipe_run_status to check progress."}`
|
|
7592
|
+
}
|
|
7593
|
+
]
|
|
7594
|
+
};
|
|
7595
|
+
}
|
|
7596
|
+
);
|
|
7597
|
+
server.tool(
|
|
7598
|
+
"get_recipe_run_status",
|
|
7599
|
+
"Check the status of a running recipe execution. Shows progress, current step, credits used, and outputs when complete.",
|
|
7600
|
+
{
|
|
7601
|
+
run_id: z17.string().describe("The recipe run ID returned by execute_recipe"),
|
|
7602
|
+
response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
7603
|
+
},
|
|
7604
|
+
async ({ run_id, response_format }) => {
|
|
7605
|
+
const format = response_format ?? "text";
|
|
7606
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
7607
|
+
action: "get-recipe-run-status",
|
|
7608
|
+
run_id
|
|
7609
|
+
});
|
|
7610
|
+
if (efError) {
|
|
7611
|
+
return {
|
|
7612
|
+
content: [{ type: "text", text: `Error: ${efError}` }],
|
|
7613
|
+
isError: true
|
|
7614
|
+
};
|
|
7615
|
+
}
|
|
7616
|
+
const run = result?.run;
|
|
7617
|
+
if (!run) {
|
|
7618
|
+
return {
|
|
7619
|
+
content: [
|
|
7620
|
+
{
|
|
7621
|
+
type: "text",
|
|
7622
|
+
text: `Run "${run_id}" not found.`
|
|
7623
|
+
}
|
|
7624
|
+
],
|
|
7625
|
+
isError: true
|
|
7626
|
+
};
|
|
7627
|
+
}
|
|
7628
|
+
if (format === "json") {
|
|
7629
|
+
return {
|
|
7630
|
+
content: [
|
|
7631
|
+
{
|
|
7632
|
+
type: "text",
|
|
7633
|
+
text: JSON.stringify(asEnvelope13(run))
|
|
7634
|
+
}
|
|
7635
|
+
]
|
|
7636
|
+
};
|
|
7637
|
+
}
|
|
7638
|
+
const statusEmoji = run.status === "completed" ? "Done" : run.status === "failed" ? "Failed" : run.status === "running" ? "Running" : run.status;
|
|
7639
|
+
return {
|
|
7640
|
+
content: [
|
|
7641
|
+
{
|
|
7642
|
+
type: "text",
|
|
7643
|
+
text: [
|
|
7644
|
+
`**Recipe Run:** ${run.id}`,
|
|
7645
|
+
`**Status:** ${statusEmoji}`,
|
|
7646
|
+
`**Progress:** ${run.progress}%`,
|
|
7647
|
+
run.current_step ? `**Current step:** ${run.current_step}` : "",
|
|
7648
|
+
`**Credits used:** ${run.credits_used}`,
|
|
7649
|
+
run.completed_at ? `**Completed:** ${run.completed_at}` : "",
|
|
7650
|
+
run.outputs ? `
|
|
7651
|
+
**Outputs:**
|
|
7652
|
+
\`\`\`json
|
|
7653
|
+
${JSON.stringify(run.outputs, null, 2)}
|
|
7654
|
+
\`\`\`` : ""
|
|
7655
|
+
].filter(Boolean).join("\n")
|
|
7656
|
+
}
|
|
7657
|
+
]
|
|
7658
|
+
};
|
|
7659
|
+
}
|
|
7660
|
+
);
|
|
7661
|
+
}
|
|
7662
|
+
|
|
7663
|
+
// src/tools/extraction.ts
|
|
7664
|
+
import { z as z18 } from "zod";
|
|
7665
|
+
init_supabase();
|
|
7666
|
+
function asEnvelope14(data) {
|
|
7667
|
+
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
7668
|
+
}
|
|
7669
|
+
function isYouTubeUrl(url) {
|
|
7670
|
+
if (/youtube\.com\/watch|youtu\.be\//.test(url)) return "video";
|
|
7671
|
+
if (/youtube\.com\/@/.test(url)) return "channel";
|
|
7672
|
+
return false;
|
|
7673
|
+
}
|
|
7674
|
+
function formatExtractedContentAsText(content) {
|
|
7675
|
+
const lines = [];
|
|
7676
|
+
lines.push(`Source: ${content.source_type} (${content.url})`);
|
|
7677
|
+
lines.push(`Title: ${content.title}`);
|
|
7678
|
+
if (content.description) lines.push(`
|
|
7679
|
+
Description:
|
|
7680
|
+
${content.description}`);
|
|
7681
|
+
if (content.transcript)
|
|
7682
|
+
lines.push(
|
|
7683
|
+
`
|
|
7684
|
+
Transcript:
|
|
7685
|
+
${content.transcript.slice(0, 3e3)}${content.transcript.length > 3e3 ? "\n... (truncated)" : ""}`
|
|
7686
|
+
);
|
|
7687
|
+
if (content.video_metadata) {
|
|
7688
|
+
const m = content.video_metadata;
|
|
7689
|
+
lines.push(`
|
|
7690
|
+
Metadata:`);
|
|
7691
|
+
lines.push(` Channel: ${m.channel_name}`);
|
|
7692
|
+
lines.push(` Views: ${m.views?.toLocaleString() ?? "N/A"}`);
|
|
7693
|
+
lines.push(` Likes: ${m.likes?.toLocaleString() ?? "N/A"}`);
|
|
7694
|
+
lines.push(` Duration: ${m.duration ?? "N/A"}s`);
|
|
7695
|
+
if (m.tags?.length) lines.push(` Tags: ${m.tags.join(", ")}`);
|
|
7696
|
+
}
|
|
7697
|
+
if (content.features?.length)
|
|
7698
|
+
lines.push(`
|
|
7699
|
+
Features:
|
|
7700
|
+
${content.features.map((f) => ` - ${f}`).join("\n")}`);
|
|
7171
7701
|
if (content.benefits?.length)
|
|
7172
7702
|
lines.push(`
|
|
7173
7703
|
Benefits:
|
|
@@ -7187,11 +7717,11 @@ function registerExtractionTools(server) {
|
|
|
7187
7717
|
"extract_url_content",
|
|
7188
7718
|
"Extract text content from any URL \u2014 YouTube video transcripts, article text, or product page features/benefits/USP. YouTube URLs auto-route to transcript extraction with optional comments. Use before generate_content to repurpose existing content, or before plan_content_week to base a content plan on a source URL.",
|
|
7189
7719
|
{
|
|
7190
|
-
url:
|
|
7191
|
-
extract_type:
|
|
7192
|
-
include_comments:
|
|
7193
|
-
max_results:
|
|
7194
|
-
response_format:
|
|
7720
|
+
url: z18.string().url().describe("URL to extract content from"),
|
|
7721
|
+
extract_type: z18.enum(["auto", "transcript", "article", "product"]).default("auto").describe("Type of extraction"),
|
|
7722
|
+
include_comments: z18.boolean().default(false).describe("Include top comments (YouTube only)"),
|
|
7723
|
+
max_results: z18.number().min(1).max(100).default(10).describe("Max comments to include"),
|
|
7724
|
+
response_format: z18.enum(["text", "json"]).default("text")
|
|
7195
7725
|
},
|
|
7196
7726
|
async ({ url, extract_type, include_comments, max_results, response_format }) => {
|
|
7197
7727
|
const startedAt = Date.now();
|
|
@@ -7326,7 +7856,7 @@ function registerExtractionTools(server) {
|
|
|
7326
7856
|
if (response_format === "json") {
|
|
7327
7857
|
return {
|
|
7328
7858
|
content: [
|
|
7329
|
-
{ type: "text", text: JSON.stringify(
|
|
7859
|
+
{ type: "text", text: JSON.stringify(asEnvelope14(extracted), null, 2) }
|
|
7330
7860
|
],
|
|
7331
7861
|
isError: false
|
|
7332
7862
|
};
|
|
@@ -7337,7 +7867,7 @@ function registerExtractionTools(server) {
|
|
|
7337
7867
|
};
|
|
7338
7868
|
} catch (err) {
|
|
7339
7869
|
const durationMs = Date.now() - startedAt;
|
|
7340
|
-
const message =
|
|
7870
|
+
const message = sanitizeError(err);
|
|
7341
7871
|
logMcpToolInvocation({
|
|
7342
7872
|
toolName: "extract_url_content",
|
|
7343
7873
|
status: "error",
|
|
@@ -7354,9 +7884,9 @@ function registerExtractionTools(server) {
|
|
|
7354
7884
|
}
|
|
7355
7885
|
|
|
7356
7886
|
// src/tools/quality.ts
|
|
7357
|
-
import { z as
|
|
7887
|
+
import { z as z19 } from "zod";
|
|
7358
7888
|
init_supabase();
|
|
7359
|
-
function
|
|
7889
|
+
function asEnvelope15(data) {
|
|
7360
7890
|
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
7361
7891
|
}
|
|
7362
7892
|
function registerQualityTools(server) {
|
|
@@ -7364,12 +7894,12 @@ function registerQualityTools(server) {
|
|
|
7364
7894
|
"quality_check",
|
|
7365
7895
|
"Score post quality across 7 categories: Hook Strength, Message Clarity, Platform Fit, Brand Alignment, Novelty, CTA Strength, and Safety/Claims. Each scored 0-5, total 35. Default pass threshold is 26 (~75%). Run after generate_content and before schedule_post. Include hashtags in caption if they will be published \u2014 they affect Platform Fit and Safety scores.",
|
|
7366
7896
|
{
|
|
7367
|
-
caption:
|
|
7897
|
+
caption: z19.string().describe(
|
|
7368
7898
|
"The post text to score. Include hashtags if they will be published \u2014 they affect Platform Fit and Safety/Claims scores."
|
|
7369
7899
|
),
|
|
7370
|
-
title:
|
|
7371
|
-
platforms:
|
|
7372
|
-
|
|
7900
|
+
title: z19.string().optional().describe("Post title (important for YouTube)"),
|
|
7901
|
+
platforms: z19.array(
|
|
7902
|
+
z19.enum([
|
|
7373
7903
|
"youtube",
|
|
7374
7904
|
"tiktok",
|
|
7375
7905
|
"instagram",
|
|
@@ -7380,13 +7910,13 @@ function registerQualityTools(server) {
|
|
|
7380
7910
|
"bluesky"
|
|
7381
7911
|
])
|
|
7382
7912
|
).min(1).describe("Target platforms"),
|
|
7383
|
-
threshold:
|
|
7913
|
+
threshold: z19.number().min(0).max(35).default(26).describe(
|
|
7384
7914
|
"Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."
|
|
7385
7915
|
),
|
|
7386
|
-
brand_keyword:
|
|
7387
|
-
brand_avoid_patterns:
|
|
7388
|
-
custom_banned_terms:
|
|
7389
|
-
response_format:
|
|
7916
|
+
brand_keyword: z19.string().optional().describe("Brand keyword for alignment check"),
|
|
7917
|
+
brand_avoid_patterns: z19.array(z19.string()).optional(),
|
|
7918
|
+
custom_banned_terms: z19.array(z19.string()).optional(),
|
|
7919
|
+
response_format: z19.enum(["text", "json"]).default("text")
|
|
7390
7920
|
},
|
|
7391
7921
|
async ({
|
|
7392
7922
|
caption,
|
|
@@ -7417,7 +7947,7 @@ function registerQualityTools(server) {
|
|
|
7417
7947
|
});
|
|
7418
7948
|
if (response_format === "json") {
|
|
7419
7949
|
return {
|
|
7420
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
7950
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope15(result), null, 2) }],
|
|
7421
7951
|
isError: false
|
|
7422
7952
|
};
|
|
7423
7953
|
}
|
|
@@ -7445,20 +7975,20 @@ function registerQualityTools(server) {
|
|
|
7445
7975
|
"quality_check_plan",
|
|
7446
7976
|
"Batch quality check all posts in a content plan. Returns per-post scores and aggregate pass/fail summary. Use after plan_content_week and before schedule_content_plan to catch low-quality posts before publishing.",
|
|
7447
7977
|
{
|
|
7448
|
-
plan:
|
|
7449
|
-
posts:
|
|
7450
|
-
|
|
7451
|
-
id:
|
|
7452
|
-
caption:
|
|
7453
|
-
title:
|
|
7454
|
-
platform:
|
|
7978
|
+
plan: z19.object({
|
|
7979
|
+
posts: z19.array(
|
|
7980
|
+
z19.object({
|
|
7981
|
+
id: z19.string(),
|
|
7982
|
+
caption: z19.string(),
|
|
7983
|
+
title: z19.string().optional(),
|
|
7984
|
+
platform: z19.string()
|
|
7455
7985
|
})
|
|
7456
7986
|
)
|
|
7457
7987
|
}).passthrough().describe("Content plan with posts array"),
|
|
7458
|
-
threshold:
|
|
7988
|
+
threshold: z19.number().min(0).max(35).default(26).describe(
|
|
7459
7989
|
"Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."
|
|
7460
7990
|
),
|
|
7461
|
-
response_format:
|
|
7991
|
+
response_format: z19.enum(["text", "json"]).default("text")
|
|
7462
7992
|
},
|
|
7463
7993
|
async ({ plan, threshold, response_format }) => {
|
|
7464
7994
|
const startedAt = Date.now();
|
|
@@ -7500,7 +8030,7 @@ function registerQualityTools(server) {
|
|
|
7500
8030
|
content: [
|
|
7501
8031
|
{
|
|
7502
8032
|
type: "text",
|
|
7503
|
-
text: JSON.stringify(
|
|
8033
|
+
text: JSON.stringify(asEnvelope15({ posts: postsWithQuality, summary }), null, 2)
|
|
7504
8034
|
}
|
|
7505
8035
|
],
|
|
7506
8036
|
isError: false
|
|
@@ -7524,7 +8054,7 @@ function registerQualityTools(server) {
|
|
|
7524
8054
|
}
|
|
7525
8055
|
|
|
7526
8056
|
// src/tools/planning.ts
|
|
7527
|
-
import { z as
|
|
8057
|
+
import { z as z20 } from "zod";
|
|
7528
8058
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
7529
8059
|
init_supabase();
|
|
7530
8060
|
|
|
@@ -7552,7 +8082,7 @@ function extractJsonArray(text) {
|
|
|
7552
8082
|
function toRecord(value) {
|
|
7553
8083
|
return value && typeof value === "object" ? value : void 0;
|
|
7554
8084
|
}
|
|
7555
|
-
function
|
|
8085
|
+
function asEnvelope16(data) {
|
|
7556
8086
|
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
7557
8087
|
}
|
|
7558
8088
|
function tomorrowIsoDate() {
|
|
@@ -7622,10 +8152,10 @@ function registerPlanningTools(server) {
|
|
|
7622
8152
|
"plan_content_week",
|
|
7623
8153
|
"Generate a full content plan with platform-specific drafts, hooks, angles, and optimal schedule times. Pass a topic or source_url \u2014 brand context and performance insights auto-load via project_id. Output feeds directly into quality_check_plan then schedule_content_plan. Costs ~5-15 credits depending on post count.",
|
|
7624
8154
|
{
|
|
7625
|
-
topic:
|
|
7626
|
-
source_url:
|
|
7627
|
-
platforms:
|
|
7628
|
-
|
|
8155
|
+
topic: z20.string().describe("Main topic or content theme"),
|
|
8156
|
+
source_url: z20.string().optional().describe("URL to extract content from (YouTube, article)"),
|
|
8157
|
+
platforms: z20.array(
|
|
8158
|
+
z20.enum([
|
|
7629
8159
|
"youtube",
|
|
7630
8160
|
"tiktok",
|
|
7631
8161
|
"instagram",
|
|
@@ -7636,12 +8166,12 @@ function registerPlanningTools(server) {
|
|
|
7636
8166
|
"bluesky"
|
|
7637
8167
|
])
|
|
7638
8168
|
).min(1).describe("Target platforms"),
|
|
7639
|
-
posts_per_day:
|
|
7640
|
-
days:
|
|
7641
|
-
start_date:
|
|
7642
|
-
brand_voice:
|
|
7643
|
-
project_id:
|
|
7644
|
-
response_format:
|
|
8169
|
+
posts_per_day: z20.number().min(1).max(5).default(1).describe("Posts per platform per day"),
|
|
8170
|
+
days: z20.number().min(1).max(7).default(5).describe("Number of days to plan"),
|
|
8171
|
+
start_date: z20.string().optional().describe("ISO date, defaults to tomorrow"),
|
|
8172
|
+
brand_voice: z20.string().optional().describe("Override brand voice description"),
|
|
8173
|
+
project_id: z20.string().optional().describe("Project ID for brand/insights context"),
|
|
8174
|
+
response_format: z20.enum(["text", "json"]).default("json")
|
|
7645
8175
|
},
|
|
7646
8176
|
async ({
|
|
7647
8177
|
topic,
|
|
@@ -7893,7 +8423,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
7893
8423
|
}
|
|
7894
8424
|
} catch (persistErr) {
|
|
7895
8425
|
const durationMs2 = Date.now() - startedAt;
|
|
7896
|
-
const message =
|
|
8426
|
+
const message = sanitizeError(persistErr);
|
|
7897
8427
|
logMcpToolInvocation({
|
|
7898
8428
|
toolName: "plan_content_week",
|
|
7899
8429
|
status: "error",
|
|
@@ -7915,7 +8445,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
7915
8445
|
});
|
|
7916
8446
|
if (response_format === "json") {
|
|
7917
8447
|
return {
|
|
7918
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8448
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope16(plan), null, 2) }],
|
|
7919
8449
|
isError: false
|
|
7920
8450
|
};
|
|
7921
8451
|
}
|
|
@@ -7925,7 +8455,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
7925
8455
|
};
|
|
7926
8456
|
} catch (err) {
|
|
7927
8457
|
const durationMs = Date.now() - startedAt;
|
|
7928
|
-
const message =
|
|
8458
|
+
const message = sanitizeError(err);
|
|
7929
8459
|
logMcpToolInvocation({
|
|
7930
8460
|
toolName: "plan_content_week",
|
|
7931
8461
|
status: "error",
|
|
@@ -7943,13 +8473,13 @@ ${rawText.slice(0, 1e3)}`
|
|
|
7943
8473
|
"save_content_plan",
|
|
7944
8474
|
"Save a content plan to the database for team review, approval workflows, and scheduled publishing. Creates a plan_id you can reference in get_content_plan, update_content_plan, and schedule_content_plan.",
|
|
7945
8475
|
{
|
|
7946
|
-
plan:
|
|
7947
|
-
topic:
|
|
7948
|
-
posts:
|
|
8476
|
+
plan: z20.object({
|
|
8477
|
+
topic: z20.string(),
|
|
8478
|
+
posts: z20.array(z20.record(z20.string(), z20.unknown()))
|
|
7949
8479
|
}).passthrough(),
|
|
7950
|
-
project_id:
|
|
7951
|
-
status:
|
|
7952
|
-
response_format:
|
|
8480
|
+
project_id: z20.string().uuid().optional(),
|
|
8481
|
+
status: z20.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft"),
|
|
8482
|
+
response_format: z20.enum(["text", "json"]).default("json")
|
|
7953
8483
|
},
|
|
7954
8484
|
async ({ plan, project_id, status, response_format }) => {
|
|
7955
8485
|
const startedAt = Date.now();
|
|
@@ -8005,7 +8535,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8005
8535
|
};
|
|
8006
8536
|
if (response_format === "json") {
|
|
8007
8537
|
return {
|
|
8008
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8538
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope16(result), null, 2) }],
|
|
8009
8539
|
isError: false
|
|
8010
8540
|
};
|
|
8011
8541
|
}
|
|
@@ -8017,7 +8547,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8017
8547
|
};
|
|
8018
8548
|
} catch (err) {
|
|
8019
8549
|
const durationMs = Date.now() - startedAt;
|
|
8020
|
-
const message =
|
|
8550
|
+
const message = sanitizeError(err);
|
|
8021
8551
|
logMcpToolInvocation({
|
|
8022
8552
|
toolName: "save_content_plan",
|
|
8023
8553
|
status: "error",
|
|
@@ -8035,8 +8565,8 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8035
8565
|
"get_content_plan",
|
|
8036
8566
|
"Retrieve a persisted content plan by ID.",
|
|
8037
8567
|
{
|
|
8038
|
-
plan_id:
|
|
8039
|
-
response_format:
|
|
8568
|
+
plan_id: z20.string().uuid().describe("Persisted content plan ID"),
|
|
8569
|
+
response_format: z20.enum(["text", "json"]).default("json")
|
|
8040
8570
|
},
|
|
8041
8571
|
async ({ plan_id, response_format }) => {
|
|
8042
8572
|
const { data: result, error } = await callEdgeFunction("mcp-data", { action: "get-content-plan", plan_id }, { timeoutMs: 1e4 });
|
|
@@ -8071,7 +8601,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8071
8601
|
};
|
|
8072
8602
|
if (response_format === "json") {
|
|
8073
8603
|
return {
|
|
8074
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8604
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
|
|
8075
8605
|
isError: false
|
|
8076
8606
|
};
|
|
8077
8607
|
}
|
|
@@ -8089,23 +8619,23 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8089
8619
|
"update_content_plan",
|
|
8090
8620
|
"Update individual posts in a persisted content plan.",
|
|
8091
8621
|
{
|
|
8092
|
-
plan_id:
|
|
8093
|
-
post_updates:
|
|
8094
|
-
|
|
8095
|
-
post_id:
|
|
8096
|
-
caption:
|
|
8097
|
-
title:
|
|
8098
|
-
hashtags:
|
|
8099
|
-
hook:
|
|
8100
|
-
angle:
|
|
8101
|
-
visual_direction:
|
|
8102
|
-
media_url:
|
|
8103
|
-
schedule_at:
|
|
8104
|
-
platform:
|
|
8105
|
-
status:
|
|
8622
|
+
plan_id: z20.string().uuid(),
|
|
8623
|
+
post_updates: z20.array(
|
|
8624
|
+
z20.object({
|
|
8625
|
+
post_id: z20.string(),
|
|
8626
|
+
caption: z20.string().optional(),
|
|
8627
|
+
title: z20.string().optional(),
|
|
8628
|
+
hashtags: z20.array(z20.string()).optional(),
|
|
8629
|
+
hook: z20.string().optional(),
|
|
8630
|
+
angle: z20.string().optional(),
|
|
8631
|
+
visual_direction: z20.string().optional(),
|
|
8632
|
+
media_url: z20.string().optional(),
|
|
8633
|
+
schedule_at: z20.string().optional(),
|
|
8634
|
+
platform: z20.string().optional(),
|
|
8635
|
+
status: z20.enum(["approved", "rejected", "needs_edit"]).optional()
|
|
8106
8636
|
})
|
|
8107
8637
|
).min(1),
|
|
8108
|
-
response_format:
|
|
8638
|
+
response_format: z20.enum(["text", "json"]).default("json")
|
|
8109
8639
|
},
|
|
8110
8640
|
async ({ plan_id, post_updates, response_format }) => {
|
|
8111
8641
|
const { data: result, error } = await callEdgeFunction(
|
|
@@ -8143,7 +8673,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8143
8673
|
};
|
|
8144
8674
|
if (response_format === "json") {
|
|
8145
8675
|
return {
|
|
8146
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8676
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
|
|
8147
8677
|
isError: false
|
|
8148
8678
|
};
|
|
8149
8679
|
}
|
|
@@ -8162,8 +8692,8 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8162
8692
|
"submit_content_plan_for_approval",
|
|
8163
8693
|
"Create pending approval items for each post in a plan and mark plan status as in_review.",
|
|
8164
8694
|
{
|
|
8165
|
-
plan_id:
|
|
8166
|
-
response_format:
|
|
8695
|
+
plan_id: z20.string().uuid(),
|
|
8696
|
+
response_format: z20.enum(["text", "json"]).default("json")
|
|
8167
8697
|
},
|
|
8168
8698
|
async ({ plan_id, response_format }) => {
|
|
8169
8699
|
const { data: result, error } = await callEdgeFunction("mcp-data", { action: "submit-plan-approval", plan_id }, { timeoutMs: 15e3 });
|
|
@@ -8196,7 +8726,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8196
8726
|
};
|
|
8197
8727
|
if (response_format === "json") {
|
|
8198
8728
|
return {
|
|
8199
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8729
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
|
|
8200
8730
|
isError: false
|
|
8201
8731
|
};
|
|
8202
8732
|
}
|
|
@@ -8214,9 +8744,9 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8214
8744
|
}
|
|
8215
8745
|
|
|
8216
8746
|
// src/tools/plan-approvals.ts
|
|
8217
|
-
import { z as
|
|
8747
|
+
import { z as z21 } from "zod";
|
|
8218
8748
|
init_supabase();
|
|
8219
|
-
function
|
|
8749
|
+
function asEnvelope17(data) {
|
|
8220
8750
|
return {
|
|
8221
8751
|
_meta: {
|
|
8222
8752
|
version: MCP_VERSION,
|
|
@@ -8230,19 +8760,19 @@ function registerPlanApprovalTools(server) {
|
|
|
8230
8760
|
"create_plan_approvals",
|
|
8231
8761
|
"Create pending approval rows for each post in a content plan.",
|
|
8232
8762
|
{
|
|
8233
|
-
plan_id:
|
|
8234
|
-
posts:
|
|
8235
|
-
|
|
8236
|
-
id:
|
|
8237
|
-
platform:
|
|
8238
|
-
caption:
|
|
8239
|
-
title:
|
|
8240
|
-
media_url:
|
|
8241
|
-
schedule_at:
|
|
8763
|
+
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
8764
|
+
posts: z21.array(
|
|
8765
|
+
z21.object({
|
|
8766
|
+
id: z21.string(),
|
|
8767
|
+
platform: z21.string().optional(),
|
|
8768
|
+
caption: z21.string().optional(),
|
|
8769
|
+
title: z21.string().optional(),
|
|
8770
|
+
media_url: z21.string().optional(),
|
|
8771
|
+
schedule_at: z21.string().optional()
|
|
8242
8772
|
}).passthrough()
|
|
8243
8773
|
).min(1).describe("Posts to create approval entries for."),
|
|
8244
|
-
project_id:
|
|
8245
|
-
response_format:
|
|
8774
|
+
project_id: z21.string().uuid().optional().describe("Project ID. Defaults to active project context."),
|
|
8775
|
+
response_format: z21.enum(["text", "json"]).optional()
|
|
8246
8776
|
},
|
|
8247
8777
|
async ({ plan_id, posts, project_id, response_format }) => {
|
|
8248
8778
|
const projectId = project_id || await getDefaultProjectId();
|
|
@@ -8291,7 +8821,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8291
8821
|
};
|
|
8292
8822
|
if ((response_format || "text") === "json") {
|
|
8293
8823
|
return {
|
|
8294
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8824
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope17(payload), null, 2) }],
|
|
8295
8825
|
isError: false
|
|
8296
8826
|
};
|
|
8297
8827
|
}
|
|
@@ -8310,9 +8840,9 @@ function registerPlanApprovalTools(server) {
|
|
|
8310
8840
|
"list_plan_approvals",
|
|
8311
8841
|
"List MCP-native approval items for a specific content plan.",
|
|
8312
8842
|
{
|
|
8313
|
-
plan_id:
|
|
8314
|
-
status:
|
|
8315
|
-
response_format:
|
|
8843
|
+
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
8844
|
+
status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
|
|
8845
|
+
response_format: z21.enum(["text", "json"]).optional()
|
|
8316
8846
|
},
|
|
8317
8847
|
async ({ plan_id, status, response_format }) => {
|
|
8318
8848
|
const { data: result, error } = await callEdgeFunction(
|
|
@@ -8343,7 +8873,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8343
8873
|
};
|
|
8344
8874
|
if ((response_format || "text") === "json") {
|
|
8345
8875
|
return {
|
|
8346
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8876
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope17(payload), null, 2) }],
|
|
8347
8877
|
isError: false
|
|
8348
8878
|
};
|
|
8349
8879
|
}
|
|
@@ -8370,11 +8900,11 @@ function registerPlanApprovalTools(server) {
|
|
|
8370
8900
|
"respond_plan_approval",
|
|
8371
8901
|
"Approve, reject, or edit a pending plan approval item.",
|
|
8372
8902
|
{
|
|
8373
|
-
approval_id:
|
|
8374
|
-
decision:
|
|
8375
|
-
edited_post:
|
|
8376
|
-
reason:
|
|
8377
|
-
response_format:
|
|
8903
|
+
approval_id: z21.string().uuid().describe("Approval item ID"),
|
|
8904
|
+
decision: z21.enum(["approved", "rejected", "edited"]),
|
|
8905
|
+
edited_post: z21.record(z21.string(), z21.unknown()).optional(),
|
|
8906
|
+
reason: z21.string().max(1e3).optional(),
|
|
8907
|
+
response_format: z21.enum(["text", "json"]).optional()
|
|
8378
8908
|
},
|
|
8379
8909
|
async ({ approval_id, decision, edited_post, reason, response_format }) => {
|
|
8380
8910
|
if (decision === "edited" && !edited_post) {
|
|
@@ -8421,7 +8951,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8421
8951
|
}
|
|
8422
8952
|
if ((response_format || "text") === "json") {
|
|
8423
8953
|
return {
|
|
8424
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
8954
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope17(data), null, 2) }],
|
|
8425
8955
|
isError: false
|
|
8426
8956
|
};
|
|
8427
8957
|
}
|
|
@@ -8439,7 +8969,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8439
8969
|
}
|
|
8440
8970
|
|
|
8441
8971
|
// src/tools/discovery.ts
|
|
8442
|
-
import { z as
|
|
8972
|
+
import { z as z22 } from "zod";
|
|
8443
8973
|
|
|
8444
8974
|
// src/lib/tool-catalog.ts
|
|
8445
8975
|
var TOOL_CATALOG = [
|
|
@@ -8506,6 +9036,12 @@ var TOOL_CATALOG = [
|
|
|
8506
9036
|
module: "content",
|
|
8507
9037
|
scope: "mcp:write"
|
|
8508
9038
|
},
|
|
9039
|
+
{
|
|
9040
|
+
name: "create_carousel",
|
|
9041
|
+
description: "End-to-end carousel: generate text + kick off image jobs for each slide",
|
|
9042
|
+
module: "carousel",
|
|
9043
|
+
scope: "mcp:write"
|
|
9044
|
+
},
|
|
8509
9045
|
// media
|
|
8510
9046
|
{
|
|
8511
9047
|
name: "upload_media",
|
|
@@ -8863,20 +9399,59 @@ var TOOL_CATALOG = [
|
|
|
8863
9399
|
description: "Create a new autopilot configuration",
|
|
8864
9400
|
module: "autopilot",
|
|
8865
9401
|
scope: "mcp:autopilot"
|
|
8866
|
-
}
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
}
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
}
|
|
9402
|
+
},
|
|
9403
|
+
// brand runtime (additions)
|
|
9404
|
+
{
|
|
9405
|
+
name: "audit_brand_colors",
|
|
9406
|
+
description: "Audit brand color palette for accessibility, contrast, and harmony",
|
|
9407
|
+
module: "brandRuntime",
|
|
9408
|
+
scope: "mcp:read"
|
|
9409
|
+
},
|
|
9410
|
+
{
|
|
9411
|
+
name: "export_design_tokens",
|
|
9412
|
+
description: "Export brand design tokens in CSS/Tailwind/JSON formats",
|
|
9413
|
+
module: "brandRuntime",
|
|
9414
|
+
scope: "mcp:read"
|
|
9415
|
+
},
|
|
9416
|
+
// carousel (already listed in content section above)
|
|
9417
|
+
// recipes
|
|
9418
|
+
{
|
|
9419
|
+
name: "list_recipes",
|
|
9420
|
+
description: "List available recipe templates for automated content workflows",
|
|
9421
|
+
module: "recipes",
|
|
9422
|
+
scope: "mcp:read"
|
|
9423
|
+
},
|
|
9424
|
+
{
|
|
9425
|
+
name: "get_recipe_details",
|
|
9426
|
+
description: "Get full details of a recipe template including steps and required inputs",
|
|
9427
|
+
module: "recipes",
|
|
9428
|
+
scope: "mcp:read"
|
|
9429
|
+
},
|
|
9430
|
+
{
|
|
9431
|
+
name: "execute_recipe",
|
|
9432
|
+
description: "Execute a recipe template with provided inputs to run a multi-step workflow",
|
|
9433
|
+
module: "recipes",
|
|
9434
|
+
scope: "mcp:write"
|
|
9435
|
+
},
|
|
9436
|
+
{
|
|
9437
|
+
name: "get_recipe_run_status",
|
|
9438
|
+
description: "Check the status and progress of a running recipe execution",
|
|
9439
|
+
module: "recipes",
|
|
9440
|
+
scope: "mcp:read"
|
|
9441
|
+
}
|
|
9442
|
+
];
|
|
9443
|
+
function getToolsByModule(module) {
|
|
9444
|
+
return TOOL_CATALOG.filter((t) => t.module === module);
|
|
9445
|
+
}
|
|
9446
|
+
function getToolsByScope(scope) {
|
|
9447
|
+
return TOOL_CATALOG.filter((t) => t.scope === scope);
|
|
9448
|
+
}
|
|
9449
|
+
function searchTools(query) {
|
|
9450
|
+
const q = query.toLowerCase();
|
|
9451
|
+
return TOOL_CATALOG.filter(
|
|
9452
|
+
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)
|
|
9453
|
+
);
|
|
9454
|
+
}
|
|
8880
9455
|
|
|
8881
9456
|
// src/tools/discovery.ts
|
|
8882
9457
|
function registerDiscoveryTools(server) {
|
|
@@ -8884,10 +9459,10 @@ function registerDiscoveryTools(server) {
|
|
|
8884
9459
|
"search_tools",
|
|
8885
9460
|
'Search available tools by name, description, module, or scope. Use "name" detail (~50 tokens) for quick lookup, "summary" (~500 tokens) for descriptions, "full" for complete input schemas. Start here if unsure which tool to call \u2014 filter by module (e.g. "planning", "content", "analytics") to narrow results.',
|
|
8886
9461
|
{
|
|
8887
|
-
query:
|
|
8888
|
-
module:
|
|
8889
|
-
scope:
|
|
8890
|
-
detail:
|
|
9462
|
+
query: z22.string().optional().describe("Search query to filter tools by name or description"),
|
|
9463
|
+
module: z22.string().optional().describe('Filter by module name (e.g. "planning", "content", "analytics")'),
|
|
9464
|
+
scope: z22.string().optional().describe('Filter by required scope (e.g. "mcp:read", "mcp:write")'),
|
|
9465
|
+
detail: z22.enum(["name", "summary", "full"]).default("summary").describe(
|
|
8891
9466
|
'Detail level: "name" for just tool names, "summary" for names + descriptions, "full" for complete info including scope and module'
|
|
8892
9467
|
)
|
|
8893
9468
|
},
|
|
@@ -8930,13 +9505,13 @@ function registerDiscoveryTools(server) {
|
|
|
8930
9505
|
}
|
|
8931
9506
|
|
|
8932
9507
|
// src/tools/pipeline.ts
|
|
8933
|
-
import { z as
|
|
9508
|
+
import { z as z23 } from "zod";
|
|
8934
9509
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
8935
9510
|
init_supabase();
|
|
8936
|
-
function
|
|
9511
|
+
function asEnvelope18(data) {
|
|
8937
9512
|
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
8938
9513
|
}
|
|
8939
|
-
var PLATFORM_ENUM2 =
|
|
9514
|
+
var PLATFORM_ENUM2 = z23.enum([
|
|
8940
9515
|
"youtube",
|
|
8941
9516
|
"tiktok",
|
|
8942
9517
|
"instagram",
|
|
@@ -8953,10 +9528,10 @@ function registerPipelineTools(server) {
|
|
|
8953
9528
|
"check_pipeline_readiness",
|
|
8954
9529
|
"Pre-flight check before run_content_pipeline. Verifies: sufficient credits for estimated_posts, active OAuth on target platforms, brand profile exists, no stale insights. Returns pass/fail with specific issues to fix before running the pipeline.",
|
|
8955
9530
|
{
|
|
8956
|
-
project_id:
|
|
8957
|
-
platforms:
|
|
8958
|
-
estimated_posts:
|
|
8959
|
-
response_format:
|
|
9531
|
+
project_id: z23.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
|
|
9532
|
+
platforms: z23.array(PLATFORM_ENUM2).min(1).describe("Target platforms to check"),
|
|
9533
|
+
estimated_posts: z23.number().min(1).max(50).default(5).describe("Estimated posts to generate"),
|
|
9534
|
+
response_format: z23.enum(["text", "json"]).optional().describe("Response format")
|
|
8960
9535
|
},
|
|
8961
9536
|
async ({ project_id, platforms, estimated_posts, response_format }) => {
|
|
8962
9537
|
const format = response_format ?? "text";
|
|
@@ -9032,7 +9607,7 @@ function registerPipelineTools(server) {
|
|
|
9032
9607
|
});
|
|
9033
9608
|
if (format === "json") {
|
|
9034
9609
|
return {
|
|
9035
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
9610
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope18(result), null, 2) }]
|
|
9036
9611
|
};
|
|
9037
9612
|
}
|
|
9038
9613
|
const lines = [];
|
|
@@ -9062,7 +9637,7 @@ function registerPipelineTools(server) {
|
|
|
9062
9637
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9063
9638
|
} catch (err) {
|
|
9064
9639
|
const durationMs = Date.now() - startedAt;
|
|
9065
|
-
const message =
|
|
9640
|
+
const message = sanitizeError(err);
|
|
9066
9641
|
logMcpToolInvocation({
|
|
9067
9642
|
toolName: "check_pipeline_readiness",
|
|
9068
9643
|
status: "error",
|
|
@@ -9080,22 +9655,22 @@ function registerPipelineTools(server) {
|
|
|
9080
9655
|
"run_content_pipeline",
|
|
9081
9656
|
"Run the full content pipeline: research trends \u2192 generate plan \u2192 quality check \u2192 auto-approve \u2192 schedule posts. Chains all stages in one call for maximum efficiency. Set dry_run=true to preview the plan without publishing. Check check_pipeline_readiness first to verify credits, OAuth, and brand profile are ready.",
|
|
9082
9657
|
{
|
|
9083
|
-
project_id:
|
|
9084
|
-
topic:
|
|
9085
|
-
source_url:
|
|
9086
|
-
platforms:
|
|
9087
|
-
days:
|
|
9088
|
-
posts_per_day:
|
|
9089
|
-
approval_mode:
|
|
9658
|
+
project_id: z23.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
|
|
9659
|
+
topic: z23.string().optional().describe("Content topic (required if no source_url)"),
|
|
9660
|
+
source_url: z23.string().optional().describe("URL to extract content from"),
|
|
9661
|
+
platforms: z23.array(PLATFORM_ENUM2).min(1).describe("Target platforms"),
|
|
9662
|
+
days: z23.number().min(1).max(7).default(5).describe("Days to plan"),
|
|
9663
|
+
posts_per_day: z23.number().min(1).max(3).default(1).describe("Posts per platform per day"),
|
|
9664
|
+
approval_mode: z23.enum(["auto", "review_all", "review_low_confidence"]).default("review_low_confidence").describe(
|
|
9090
9665
|
"auto: approve all passing quality. review_all: flag everything. review_low_confidence: auto-approve high scorers."
|
|
9091
9666
|
),
|
|
9092
|
-
auto_approve_threshold:
|
|
9667
|
+
auto_approve_threshold: z23.number().min(0).max(35).default(28).describe(
|
|
9093
9668
|
"Quality score threshold for auto-approval (used in auto/review_low_confidence modes)"
|
|
9094
9669
|
),
|
|
9095
|
-
max_credits:
|
|
9096
|
-
dry_run:
|
|
9097
|
-
skip_stages:
|
|
9098
|
-
response_format:
|
|
9670
|
+
max_credits: z23.number().optional().describe("Credit budget cap"),
|
|
9671
|
+
dry_run: z23.boolean().default(false).describe("If true, skip scheduling and return plan only"),
|
|
9672
|
+
skip_stages: z23.array(z23.enum(["research", "quality", "schedule"])).optional().describe("Stages to skip"),
|
|
9673
|
+
response_format: z23.enum(["text", "json"]).default("json")
|
|
9099
9674
|
},
|
|
9100
9675
|
async ({
|
|
9101
9676
|
project_id,
|
|
@@ -9223,7 +9798,7 @@ function registerPipelineTools(server) {
|
|
|
9223
9798
|
} catch (deductErr) {
|
|
9224
9799
|
errors.push({
|
|
9225
9800
|
stage: "planning",
|
|
9226
|
-
message: `Credit deduction failed: ${
|
|
9801
|
+
message: `Credit deduction failed: ${sanitizeError(deductErr)}`
|
|
9227
9802
|
});
|
|
9228
9803
|
}
|
|
9229
9804
|
}
|
|
@@ -9394,7 +9969,7 @@ function registerPipelineTools(server) {
|
|
|
9394
9969
|
} catch (schedErr) {
|
|
9395
9970
|
errors.push({
|
|
9396
9971
|
stage: "schedule",
|
|
9397
|
-
message: `Failed to schedule ${post.id}: ${
|
|
9972
|
+
message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
|
|
9398
9973
|
});
|
|
9399
9974
|
}
|
|
9400
9975
|
}
|
|
@@ -9457,7 +10032,7 @@ function registerPipelineTools(server) {
|
|
|
9457
10032
|
if (response_format === "json") {
|
|
9458
10033
|
return {
|
|
9459
10034
|
content: [
|
|
9460
|
-
{ type: "text", text: JSON.stringify(
|
|
10035
|
+
{ type: "text", text: JSON.stringify(asEnvelope18(resultPayload), null, 2) }
|
|
9461
10036
|
]
|
|
9462
10037
|
};
|
|
9463
10038
|
}
|
|
@@ -9483,7 +10058,7 @@ function registerPipelineTools(server) {
|
|
|
9483
10058
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9484
10059
|
} catch (err) {
|
|
9485
10060
|
const durationMs = Date.now() - startedAt;
|
|
9486
|
-
const message =
|
|
10061
|
+
const message = sanitizeError(err);
|
|
9487
10062
|
logMcpToolInvocation({
|
|
9488
10063
|
toolName: "run_content_pipeline",
|
|
9489
10064
|
status: "error",
|
|
@@ -9518,8 +10093,8 @@ function registerPipelineTools(server) {
|
|
|
9518
10093
|
"get_pipeline_status",
|
|
9519
10094
|
"Check status of a pipeline run, including stages completed, pending approvals, and scheduled posts.",
|
|
9520
10095
|
{
|
|
9521
|
-
pipeline_id:
|
|
9522
|
-
response_format:
|
|
10096
|
+
pipeline_id: z23.string().uuid().optional().describe("Pipeline run ID (omit for latest)"),
|
|
10097
|
+
response_format: z23.enum(["text", "json"]).optional()
|
|
9523
10098
|
},
|
|
9524
10099
|
async ({ pipeline_id, response_format }) => {
|
|
9525
10100
|
const format = response_format ?? "text";
|
|
@@ -9545,7 +10120,7 @@ function registerPipelineTools(server) {
|
|
|
9545
10120
|
}
|
|
9546
10121
|
if (format === "json") {
|
|
9547
10122
|
return {
|
|
9548
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
10123
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope18(data), null, 2) }]
|
|
9549
10124
|
};
|
|
9550
10125
|
}
|
|
9551
10126
|
const lines = [];
|
|
@@ -9579,9 +10154,9 @@ function registerPipelineTools(server) {
|
|
|
9579
10154
|
"auto_approve_plan",
|
|
9580
10155
|
"Batch auto-approve posts in a content plan that meet quality thresholds. Posts below the threshold are flagged for manual review.",
|
|
9581
10156
|
{
|
|
9582
|
-
plan_id:
|
|
9583
|
-
quality_threshold:
|
|
9584
|
-
response_format:
|
|
10157
|
+
plan_id: z23.string().uuid().describe("Content plan ID"),
|
|
10158
|
+
quality_threshold: z23.number().min(0).max(35).default(26).describe("Minimum quality score to auto-approve"),
|
|
10159
|
+
response_format: z23.enum(["text", "json"]).default("json")
|
|
9585
10160
|
},
|
|
9586
10161
|
async ({ plan_id, quality_threshold, response_format }) => {
|
|
9587
10162
|
const startedAt = Date.now();
|
|
@@ -9687,7 +10262,7 @@ function registerPipelineTools(server) {
|
|
|
9687
10262
|
if (response_format === "json") {
|
|
9688
10263
|
return {
|
|
9689
10264
|
content: [
|
|
9690
|
-
{ type: "text", text: JSON.stringify(
|
|
10265
|
+
{ type: "text", text: JSON.stringify(asEnvelope18(resultPayload), null, 2) }
|
|
9691
10266
|
]
|
|
9692
10267
|
};
|
|
9693
10268
|
}
|
|
@@ -9707,7 +10282,7 @@ function registerPipelineTools(server) {
|
|
|
9707
10282
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9708
10283
|
} catch (err) {
|
|
9709
10284
|
const durationMs = Date.now() - startedAt;
|
|
9710
|
-
const message =
|
|
10285
|
+
const message = sanitizeError(err);
|
|
9711
10286
|
logMcpToolInvocation({
|
|
9712
10287
|
toolName: "auto_approve_plan",
|
|
9713
10288
|
status: "error",
|
|
@@ -9741,9 +10316,9 @@ function buildPlanPrompt(topic, platforms, days, postsPerDay, sourceUrl) {
|
|
|
9741
10316
|
}
|
|
9742
10317
|
|
|
9743
10318
|
// src/tools/suggest.ts
|
|
9744
|
-
import { z as
|
|
10319
|
+
import { z as z24 } from "zod";
|
|
9745
10320
|
init_supabase();
|
|
9746
|
-
function
|
|
10321
|
+
function asEnvelope19(data) {
|
|
9747
10322
|
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
9748
10323
|
}
|
|
9749
10324
|
function registerSuggestTools(server) {
|
|
@@ -9751,9 +10326,9 @@ function registerSuggestTools(server) {
|
|
|
9751
10326
|
"suggest_next_content",
|
|
9752
10327
|
"Suggest next content topics based on performance insights, past content, and competitor patterns. No AI call, no credit cost \u2014 purely data-driven recommendations.",
|
|
9753
10328
|
{
|
|
9754
|
-
project_id:
|
|
9755
|
-
count:
|
|
9756
|
-
response_format:
|
|
10329
|
+
project_id: z24.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
|
|
10330
|
+
count: z24.number().min(1).max(10).default(3).describe("Number of suggestions to return"),
|
|
10331
|
+
response_format: z24.enum(["text", "json"]).optional()
|
|
9757
10332
|
},
|
|
9758
10333
|
async ({ project_id, count, response_format }) => {
|
|
9759
10334
|
const format = response_format ?? "text";
|
|
@@ -9863,7 +10438,7 @@ function registerSuggestTools(server) {
|
|
|
9863
10438
|
if (format === "json") {
|
|
9864
10439
|
return {
|
|
9865
10440
|
content: [
|
|
9866
|
-
{ type: "text", text: JSON.stringify(
|
|
10441
|
+
{ type: "text", text: JSON.stringify(asEnvelope19(resultPayload), null, 2) }
|
|
9867
10442
|
]
|
|
9868
10443
|
};
|
|
9869
10444
|
}
|
|
@@ -9885,7 +10460,7 @@ ${i + 1}. ${s.topic}`);
|
|
|
9885
10460
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9886
10461
|
} catch (err) {
|
|
9887
10462
|
const durationMs = Date.now() - startedAt;
|
|
9888
|
-
const message =
|
|
10463
|
+
const message = sanitizeError(err);
|
|
9889
10464
|
logMcpToolInvocation({
|
|
9890
10465
|
toolName: "suggest_next_content",
|
|
9891
10466
|
status: "error",
|
|
@@ -9902,7 +10477,7 @@ ${i + 1}. ${s.topic}`);
|
|
|
9902
10477
|
}
|
|
9903
10478
|
|
|
9904
10479
|
// src/tools/digest.ts
|
|
9905
|
-
import { z as
|
|
10480
|
+
import { z as z25 } from "zod";
|
|
9906
10481
|
init_supabase();
|
|
9907
10482
|
|
|
9908
10483
|
// src/lib/anomaly-detector.ts
|
|
@@ -10006,10 +10581,10 @@ function detectAnomalies(currentData, previousData, sensitivity = "medium", aver
|
|
|
10006
10581
|
}
|
|
10007
10582
|
|
|
10008
10583
|
// src/tools/digest.ts
|
|
10009
|
-
function
|
|
10584
|
+
function asEnvelope20(data) {
|
|
10010
10585
|
return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
|
|
10011
10586
|
}
|
|
10012
|
-
var PLATFORM_ENUM3 =
|
|
10587
|
+
var PLATFORM_ENUM3 = z25.enum([
|
|
10013
10588
|
"youtube",
|
|
10014
10589
|
"tiktok",
|
|
10015
10590
|
"instagram",
|
|
@@ -10024,10 +10599,10 @@ function registerDigestTools(server) {
|
|
|
10024
10599
|
"generate_performance_digest",
|
|
10025
10600
|
"Generate a performance summary for a time period. Includes metrics, trends vs previous period, top/bottom performers, platform breakdown, and actionable recommendations. No AI call, no credit cost.",
|
|
10026
10601
|
{
|
|
10027
|
-
project_id:
|
|
10028
|
-
period:
|
|
10029
|
-
include_recommendations:
|
|
10030
|
-
response_format:
|
|
10602
|
+
project_id: z25.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
|
|
10603
|
+
period: z25.enum(["7d", "14d", "30d"]).default("7d").describe("Time period to analyze"),
|
|
10604
|
+
include_recommendations: z25.boolean().default(true),
|
|
10605
|
+
response_format: z25.enum(["text", "json"]).optional()
|
|
10031
10606
|
},
|
|
10032
10607
|
async ({ project_id, period, include_recommendations, response_format }) => {
|
|
10033
10608
|
const format = response_format ?? "text";
|
|
@@ -10183,7 +10758,7 @@ function registerDigestTools(server) {
|
|
|
10183
10758
|
});
|
|
10184
10759
|
if (format === "json") {
|
|
10185
10760
|
return {
|
|
10186
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
10761
|
+
content: [{ type: "text", text: JSON.stringify(asEnvelope20(digest), null, 2) }]
|
|
10187
10762
|
};
|
|
10188
10763
|
}
|
|
10189
10764
|
const lines = [];
|
|
@@ -10224,7 +10799,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10224
10799
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10225
10800
|
} catch (err) {
|
|
10226
10801
|
const durationMs = Date.now() - startedAt;
|
|
10227
|
-
const message =
|
|
10802
|
+
const message = sanitizeError(err);
|
|
10228
10803
|
logMcpToolInvocation({
|
|
10229
10804
|
toolName: "generate_performance_digest",
|
|
10230
10805
|
status: "error",
|
|
@@ -10242,11 +10817,11 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10242
10817
|
"detect_anomalies",
|
|
10243
10818
|
"Detect significant performance changes: spikes, drops, viral content, trend shifts. Compares current period against previous equal-length period. No AI call, no credit cost.",
|
|
10244
10819
|
{
|
|
10245
|
-
project_id:
|
|
10246
|
-
days:
|
|
10247
|
-
sensitivity:
|
|
10248
|
-
platforms:
|
|
10249
|
-
response_format:
|
|
10820
|
+
project_id: z25.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
|
|
10821
|
+
days: z25.number().min(7).max(90).default(14).describe("Days to analyze"),
|
|
10822
|
+
sensitivity: z25.enum(["low", "medium", "high"]).default("medium").describe("Detection sensitivity: low=50%+, medium=30%+, high=15%+ changes"),
|
|
10823
|
+
platforms: z25.array(PLATFORM_ENUM3).optional().describe("Filter to specific platforms"),
|
|
10824
|
+
response_format: z25.enum(["text", "json"]).optional()
|
|
10250
10825
|
},
|
|
10251
10826
|
async ({ project_id, days, sensitivity, platforms, response_format }) => {
|
|
10252
10827
|
const format = response_format ?? "text";
|
|
@@ -10297,7 +10872,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10297
10872
|
if (format === "json") {
|
|
10298
10873
|
return {
|
|
10299
10874
|
content: [
|
|
10300
|
-
{ type: "text", text: JSON.stringify(
|
|
10875
|
+
{ type: "text", text: JSON.stringify(asEnvelope20(resultPayload), null, 2) }
|
|
10301
10876
|
]
|
|
10302
10877
|
};
|
|
10303
10878
|
}
|
|
@@ -10321,7 +10896,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10321
10896
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10322
10897
|
} catch (err) {
|
|
10323
10898
|
const durationMs = Date.now() - startedAt;
|
|
10324
|
-
const message =
|
|
10899
|
+
const message = sanitizeError(err);
|
|
10325
10900
|
logMcpToolInvocation({
|
|
10326
10901
|
toolName: "detect_anomalies",
|
|
10327
10902
|
status: "error",
|
|
@@ -10338,7 +10913,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10338
10913
|
}
|
|
10339
10914
|
|
|
10340
10915
|
// src/tools/brandRuntime.ts
|
|
10341
|
-
import { z as
|
|
10916
|
+
import { z as z26 } from "zod";
|
|
10342
10917
|
init_supabase();
|
|
10343
10918
|
|
|
10344
10919
|
// src/lib/brandScoring.ts
|
|
@@ -10608,8 +11183,181 @@ function computeBrandConsistency(content, profile, threshold = 60) {
|
|
|
10608
11183
|
};
|
|
10609
11184
|
}
|
|
10610
11185
|
|
|
11186
|
+
// src/lib/colorAudit.ts
|
|
11187
|
+
function hexToRgb(hex) {
|
|
11188
|
+
const h = hex.replace("#", "");
|
|
11189
|
+
const full = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
|
|
11190
|
+
const n = parseInt(full, 16);
|
|
11191
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
11192
|
+
}
|
|
11193
|
+
function srgbToLinear(c) {
|
|
11194
|
+
const s = c / 255;
|
|
11195
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
11196
|
+
}
|
|
11197
|
+
function hexToLab(hex) {
|
|
11198
|
+
const [r, g, b] = hexToRgb(hex);
|
|
11199
|
+
const lr = srgbToLinear(r);
|
|
11200
|
+
const lg = srgbToLinear(g);
|
|
11201
|
+
const lb = srgbToLinear(b);
|
|
11202
|
+
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
11203
|
+
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
11204
|
+
const z29 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
11205
|
+
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
11206
|
+
const fx = f(x / 0.95047);
|
|
11207
|
+
const fy = f(y / 1);
|
|
11208
|
+
const fz = f(z29 / 1.08883);
|
|
11209
|
+
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
11210
|
+
}
|
|
11211
|
+
function deltaE2000(lab1, lab2) {
|
|
11212
|
+
const { L: L1, a: a1, b: b1 } = lab1;
|
|
11213
|
+
const { L: L2, a: a2, b: b2 } = lab2;
|
|
11214
|
+
const C1 = Math.sqrt(a1 * a1 + b1 * b1);
|
|
11215
|
+
const C2 = Math.sqrt(a2 * a2 + b2 * b2);
|
|
11216
|
+
const Cab = (C1 + C2) / 2;
|
|
11217
|
+
const Cab7 = Math.pow(Cab, 7);
|
|
11218
|
+
const G = 0.5 * (1 - Math.sqrt(Cab7 / (Cab7 + Math.pow(25, 7))));
|
|
11219
|
+
const a1p = a1 * (1 + G);
|
|
11220
|
+
const a2p = a2 * (1 + G);
|
|
11221
|
+
const C1p = Math.sqrt(a1p * a1p + b1 * b1);
|
|
11222
|
+
const C2p = Math.sqrt(a2p * a2p + b2 * b2);
|
|
11223
|
+
let h1p = Math.atan2(b1, a1p) * (180 / Math.PI);
|
|
11224
|
+
if (h1p < 0) h1p += 360;
|
|
11225
|
+
let h2p = Math.atan2(b2, a2p) * (180 / Math.PI);
|
|
11226
|
+
if (h2p < 0) h2p += 360;
|
|
11227
|
+
const dLp = L2 - L1;
|
|
11228
|
+
const dCp = C2p - C1p;
|
|
11229
|
+
let dhp;
|
|
11230
|
+
if (C1p * C2p === 0) dhp = 0;
|
|
11231
|
+
else if (Math.abs(h2p - h1p) <= 180) dhp = h2p - h1p;
|
|
11232
|
+
else if (h2p - h1p > 180) dhp = h2p - h1p - 360;
|
|
11233
|
+
else dhp = h2p - h1p + 360;
|
|
11234
|
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp * Math.PI / 360);
|
|
11235
|
+
const Lp = (L1 + L2) / 2;
|
|
11236
|
+
const Cp = (C1p + C2p) / 2;
|
|
11237
|
+
let hp;
|
|
11238
|
+
if (C1p * C2p === 0) hp = h1p + h2p;
|
|
11239
|
+
else if (Math.abs(h1p - h2p) <= 180) hp = (h1p + h2p) / 2;
|
|
11240
|
+
else if (h1p + h2p < 360) hp = (h1p + h2p + 360) / 2;
|
|
11241
|
+
else hp = (h1p + h2p - 360) / 2;
|
|
11242
|
+
const T = 1 - 0.17 * Math.cos((hp - 30) * Math.PI / 180) + 0.24 * Math.cos(2 * hp * Math.PI / 180) + 0.32 * Math.cos((3 * hp + 6) * Math.PI / 180) - 0.2 * Math.cos((4 * hp - 63) * Math.PI / 180);
|
|
11243
|
+
const SL = 1 + 0.015 * (Lp - 50) * (Lp - 50) / Math.sqrt(20 + (Lp - 50) * (Lp - 50));
|
|
11244
|
+
const SC = 1 + 0.045 * Cp;
|
|
11245
|
+
const SH = 1 + 0.015 * Cp * T;
|
|
11246
|
+
const Cp7 = Math.pow(Cp, 7);
|
|
11247
|
+
const RT = -2 * Math.sqrt(Cp7 / (Cp7 + Math.pow(25, 7))) * Math.sin(60 * Math.exp(-Math.pow((hp - 275) / 25, 2)) * Math.PI / 180);
|
|
11248
|
+
return Math.sqrt(
|
|
11249
|
+
Math.pow(dLp / SL, 2) + Math.pow(dCp / SC, 2) + Math.pow(dHp / SH, 2) + RT * (dCp / SC) * (dHp / SH)
|
|
11250
|
+
);
|
|
11251
|
+
}
|
|
11252
|
+
var COLOR_SLOTS = [
|
|
11253
|
+
"primary",
|
|
11254
|
+
"secondary",
|
|
11255
|
+
"accent",
|
|
11256
|
+
"background",
|
|
11257
|
+
"success",
|
|
11258
|
+
"warning",
|
|
11259
|
+
"error",
|
|
11260
|
+
"text",
|
|
11261
|
+
"textSecondary"
|
|
11262
|
+
];
|
|
11263
|
+
function auditBrandColors(palette, contentColors, threshold = 10) {
|
|
11264
|
+
if (!contentColors.length) return { entries: [], overallScore: 100, passed: true };
|
|
11265
|
+
const brandColors = [];
|
|
11266
|
+
for (const slot of COLOR_SLOTS) {
|
|
11267
|
+
const hex = palette[slot];
|
|
11268
|
+
if (typeof hex === "string" && hex.startsWith("#")) {
|
|
11269
|
+
brandColors.push({ slot, hex, lab: hexToLab(hex) });
|
|
11270
|
+
}
|
|
11271
|
+
}
|
|
11272
|
+
if (!brandColors.length) return { entries: [], overallScore: 50, passed: false };
|
|
11273
|
+
const entries = contentColors.map((color) => {
|
|
11274
|
+
const colorLab = hexToLab(color);
|
|
11275
|
+
let minDE = Infinity;
|
|
11276
|
+
let closest = brandColors[0];
|
|
11277
|
+
for (const bc of brandColors) {
|
|
11278
|
+
const de = deltaE2000(colorLab, bc.lab);
|
|
11279
|
+
if (de < minDE) {
|
|
11280
|
+
minDE = de;
|
|
11281
|
+
closest = bc;
|
|
11282
|
+
}
|
|
11283
|
+
}
|
|
11284
|
+
return {
|
|
11285
|
+
color,
|
|
11286
|
+
closestBrandColor: closest.hex,
|
|
11287
|
+
closestBrandSlot: closest.slot,
|
|
11288
|
+
deltaE: Math.round(minDE * 100) / 100,
|
|
11289
|
+
passed: minDE <= threshold
|
|
11290
|
+
};
|
|
11291
|
+
});
|
|
11292
|
+
const passedCount = entries.filter((e) => e.passed).length;
|
|
11293
|
+
const overallScore = Math.round(passedCount / entries.length * 100);
|
|
11294
|
+
return { entries, overallScore, passed: overallScore >= 80 };
|
|
11295
|
+
}
|
|
11296
|
+
function exportDesignTokens(palette, typography, format) {
|
|
11297
|
+
if (format === "css") return exportCSS(palette, typography);
|
|
11298
|
+
if (format === "tailwind") return JSON.stringify(exportTailwind(palette), null, 2);
|
|
11299
|
+
return JSON.stringify(exportFigma(palette, typography), null, 2);
|
|
11300
|
+
}
|
|
11301
|
+
function exportCSS(palette, typography) {
|
|
11302
|
+
const lines = [":root {"];
|
|
11303
|
+
const slots = [
|
|
11304
|
+
["--brand-primary", "primary"],
|
|
11305
|
+
["--brand-secondary", "secondary"],
|
|
11306
|
+
["--brand-accent", "accent"],
|
|
11307
|
+
["--brand-background", "background"],
|
|
11308
|
+
["--brand-success", "success"],
|
|
11309
|
+
["--brand-warning", "warning"],
|
|
11310
|
+
["--brand-error", "error"],
|
|
11311
|
+
["--brand-text", "text"],
|
|
11312
|
+
["--brand-text-secondary", "textSecondary"]
|
|
11313
|
+
];
|
|
11314
|
+
for (const [varName, key] of slots) {
|
|
11315
|
+
const v = palette[key];
|
|
11316
|
+
if (typeof v === "string" && v) lines.push(` ${varName}: ${v};`);
|
|
11317
|
+
}
|
|
11318
|
+
if (typography) {
|
|
11319
|
+
const hf = typography.headingFont;
|
|
11320
|
+
const bf = typography.bodyFont;
|
|
11321
|
+
if (hf) lines.push(` --brand-font-heading: ${hf};`);
|
|
11322
|
+
if (bf) lines.push(` --brand-font-body: ${bf};`);
|
|
11323
|
+
}
|
|
11324
|
+
lines.push("}");
|
|
11325
|
+
return lines.join("\n");
|
|
11326
|
+
}
|
|
11327
|
+
function exportTailwind(palette) {
|
|
11328
|
+
const colors = {};
|
|
11329
|
+
const map = [
|
|
11330
|
+
["brand-primary", "primary"],
|
|
11331
|
+
["brand-secondary", "secondary"],
|
|
11332
|
+
["brand-accent", "accent"],
|
|
11333
|
+
["brand-bg", "background"]
|
|
11334
|
+
];
|
|
11335
|
+
for (const [tw, key] of map) {
|
|
11336
|
+
const v = palette[key];
|
|
11337
|
+
if (typeof v === "string" && v) colors[tw] = v;
|
|
11338
|
+
}
|
|
11339
|
+
return colors;
|
|
11340
|
+
}
|
|
11341
|
+
function exportFigma(palette, typography) {
|
|
11342
|
+
const tokens = { color: {} };
|
|
11343
|
+
for (const slot of ["primary", "secondary", "accent", "background"]) {
|
|
11344
|
+
const v = palette[slot];
|
|
11345
|
+
if (typeof v === "string") tokens.color[slot] = { value: v, type: "color" };
|
|
11346
|
+
}
|
|
11347
|
+
if (typography) {
|
|
11348
|
+
const hf = typography.headingFont;
|
|
11349
|
+
const bf = typography.bodyFont;
|
|
11350
|
+
if (hf || bf) {
|
|
11351
|
+
tokens.fontFamily = {};
|
|
11352
|
+
if (hf) tokens.fontFamily.heading = { value: String(hf), type: "fontFamilies" };
|
|
11353
|
+
if (bf) tokens.fontFamily.body = { value: String(bf), type: "fontFamilies" };
|
|
11354
|
+
}
|
|
11355
|
+
}
|
|
11356
|
+
return tokens;
|
|
11357
|
+
}
|
|
11358
|
+
|
|
10611
11359
|
// src/tools/brandRuntime.ts
|
|
10612
|
-
function
|
|
11360
|
+
function asEnvelope21(data) {
|
|
10613
11361
|
return {
|
|
10614
11362
|
_meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
10615
11363
|
data
|
|
@@ -10620,7 +11368,7 @@ function registerBrandRuntimeTools(server) {
|
|
|
10620
11368
|
"get_brand_runtime",
|
|
10621
11369
|
"Get the full brand runtime for a project. Returns the 4-layer brand system: messaging (value props, pillars, proof points), voice (tone, vocabulary, avoid patterns), visual (palette, typography, composition), and operating constraints (audience, archetype). Also returns extraction confidence metadata.",
|
|
10622
11370
|
{
|
|
10623
|
-
project_id:
|
|
11371
|
+
project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
|
|
10624
11372
|
},
|
|
10625
11373
|
async ({ project_id }) => {
|
|
10626
11374
|
const projectId = project_id || await getDefaultProjectId();
|
|
@@ -10680,7 +11428,7 @@ function registerBrandRuntimeTools(server) {
|
|
|
10680
11428
|
pagesScraped: meta.pagesScraped || 0
|
|
10681
11429
|
}
|
|
10682
11430
|
};
|
|
10683
|
-
const envelope =
|
|
11431
|
+
const envelope = asEnvelope21(runtime);
|
|
10684
11432
|
return {
|
|
10685
11433
|
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
|
|
10686
11434
|
};
|
|
@@ -10690,7 +11438,7 @@ function registerBrandRuntimeTools(server) {
|
|
|
10690
11438
|
"explain_brand_system",
|
|
10691
11439
|
"Explains what brand data is available vs missing for a project. Returns a human-readable summary of completeness, confidence levels, and recommendations for improving the brand profile.",
|
|
10692
11440
|
{
|
|
10693
|
-
project_id:
|
|
11441
|
+
project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
|
|
10694
11442
|
},
|
|
10695
11443
|
async ({ project_id }) => {
|
|
10696
11444
|
const projectId = project_id || await getDefaultProjectId();
|
|
@@ -10802,8 +11550,8 @@ function registerBrandRuntimeTools(server) {
|
|
|
10802
11550
|
"check_brand_consistency",
|
|
10803
11551
|
"Check if content text is consistent with the brand voice, vocabulary, messaging, and factual claims. Returns per-dimension scores (0-100) and specific issues found. Use this to validate scripts, captions, or post copy before publishing.",
|
|
10804
11552
|
{
|
|
10805
|
-
content:
|
|
10806
|
-
project_id:
|
|
11553
|
+
content: z26.string().describe("The content text to check for brand consistency."),
|
|
11554
|
+
project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
|
|
10807
11555
|
},
|
|
10808
11556
|
async ({ content, project_id }) => {
|
|
10809
11557
|
const projectId = project_id || await getDefaultProjectId();
|
|
@@ -10822,7 +11570,76 @@ function registerBrandRuntimeTools(server) {
|
|
|
10822
11570
|
}
|
|
10823
11571
|
const profile = row.profile_data;
|
|
10824
11572
|
const checkResult = computeBrandConsistency(content, profile);
|
|
10825
|
-
const envelope =
|
|
11573
|
+
const envelope = asEnvelope21(checkResult);
|
|
11574
|
+
return {
|
|
11575
|
+
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
|
|
11576
|
+
};
|
|
11577
|
+
}
|
|
11578
|
+
);
|
|
11579
|
+
server.tool(
|
|
11580
|
+
"audit_brand_colors",
|
|
11581
|
+
"Audit content colors against the brand palette using perceptual color distance (Delta E 2000). Returns per-color compliance scores and identifies the closest brand color for each input.",
|
|
11582
|
+
{
|
|
11583
|
+
content_colors: z26.array(z26.string()).describe('Hex color strings used in the content (e.g., ["#FF0000", "#00FF00"])'),
|
|
11584
|
+
project_id: z26.string().optional().describe("Project ID. Defaults to current project."),
|
|
11585
|
+
threshold: z26.number().optional().describe("Max Delta E for on-brand (default 10). Lower = stricter.")
|
|
11586
|
+
},
|
|
11587
|
+
async ({ content_colors, project_id, threshold }) => {
|
|
11588
|
+
const projectId = project_id || await getDefaultProjectId();
|
|
11589
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
|
|
11590
|
+
const row = !efError && result?.success ? result.profile : null;
|
|
11591
|
+
if (!row?.profile_data?.colorPalette) {
|
|
11592
|
+
return {
|
|
11593
|
+
content: [
|
|
11594
|
+
{
|
|
11595
|
+
type: "text",
|
|
11596
|
+
text: "No brand color palette found. Extract a brand profile first."
|
|
11597
|
+
}
|
|
11598
|
+
],
|
|
11599
|
+
isError: true
|
|
11600
|
+
};
|
|
11601
|
+
}
|
|
11602
|
+
const auditResult = auditBrandColors(
|
|
11603
|
+
row.profile_data.colorPalette,
|
|
11604
|
+
content_colors,
|
|
11605
|
+
threshold ?? 10
|
|
11606
|
+
);
|
|
11607
|
+
const envelope = asEnvelope21(auditResult);
|
|
11608
|
+
return {
|
|
11609
|
+
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
|
|
11610
|
+
};
|
|
11611
|
+
}
|
|
11612
|
+
);
|
|
11613
|
+
server.tool(
|
|
11614
|
+
"export_design_tokens",
|
|
11615
|
+
"Export brand palette and typography as design tokens. Supports CSS custom properties, Tailwind config, and Figma Tokens JSON formats.",
|
|
11616
|
+
{
|
|
11617
|
+
format: z26.enum(["css", "tailwind", "figma"]).describe(
|
|
11618
|
+
"Output format: css (CSS variables), tailwind (theme.extend.colors), figma (Figma Tokens JSON)"
|
|
11619
|
+
),
|
|
11620
|
+
project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
|
|
11621
|
+
},
|
|
11622
|
+
async ({ format, project_id }) => {
|
|
11623
|
+
const projectId = project_id || await getDefaultProjectId();
|
|
11624
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
|
|
11625
|
+
const row = !efError && result?.success ? result.profile : null;
|
|
11626
|
+
if (!row?.profile_data?.colorPalette) {
|
|
11627
|
+
return {
|
|
11628
|
+
content: [
|
|
11629
|
+
{
|
|
11630
|
+
type: "text",
|
|
11631
|
+
text: "No brand color palette found. Extract a brand profile first."
|
|
11632
|
+
}
|
|
11633
|
+
],
|
|
11634
|
+
isError: true
|
|
11635
|
+
};
|
|
11636
|
+
}
|
|
11637
|
+
const output = exportDesignTokens(
|
|
11638
|
+
row.profile_data.colorPalette,
|
|
11639
|
+
row.profile_data.typography,
|
|
11640
|
+
format
|
|
11641
|
+
);
|
|
11642
|
+
const envelope = asEnvelope21({ format, tokens: output });
|
|
10826
11643
|
return {
|
|
10827
11644
|
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
|
|
10828
11645
|
};
|
|
@@ -10830,6 +11647,389 @@ function registerBrandRuntimeTools(server) {
|
|
|
10830
11647
|
);
|
|
10831
11648
|
}
|
|
10832
11649
|
|
|
11650
|
+
// src/tools/carousel.ts
|
|
11651
|
+
import { z as z27 } from "zod";
|
|
11652
|
+
init_supabase();
|
|
11653
|
+
init_request_context();
|
|
11654
|
+
var MAX_CREDITS_PER_RUN2 = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
|
|
11655
|
+
var MAX_ASSETS_PER_RUN2 = Math.max(0, Number(process.env.SOCIALNEURON_MAX_ASSETS_PER_RUN || 0));
|
|
11656
|
+
var _globalCreditsUsed2 = 0;
|
|
11657
|
+
var _globalAssetsGenerated2 = 0;
|
|
11658
|
+
function getCreditsUsed2() {
|
|
11659
|
+
const ctx = requestContext.getStore();
|
|
11660
|
+
return ctx ? ctx.creditsUsed : _globalCreditsUsed2;
|
|
11661
|
+
}
|
|
11662
|
+
function addCreditsUsed2(amount) {
|
|
11663
|
+
const ctx = requestContext.getStore();
|
|
11664
|
+
if (ctx) {
|
|
11665
|
+
ctx.creditsUsed += amount;
|
|
11666
|
+
} else {
|
|
11667
|
+
_globalCreditsUsed2 += amount;
|
|
11668
|
+
}
|
|
11669
|
+
}
|
|
11670
|
+
function getAssetsGenerated2() {
|
|
11671
|
+
const ctx = requestContext.getStore();
|
|
11672
|
+
return ctx ? ctx.assetsGenerated : _globalAssetsGenerated2;
|
|
11673
|
+
}
|
|
11674
|
+
function addAssetsGenerated2(count) {
|
|
11675
|
+
const ctx = requestContext.getStore();
|
|
11676
|
+
if (ctx) {
|
|
11677
|
+
ctx.assetsGenerated += count;
|
|
11678
|
+
} else {
|
|
11679
|
+
_globalAssetsGenerated2 += count;
|
|
11680
|
+
}
|
|
11681
|
+
}
|
|
11682
|
+
function checkCreditBudget2(estimatedCost) {
|
|
11683
|
+
if (MAX_CREDITS_PER_RUN2 <= 0) return { ok: true };
|
|
11684
|
+
const used = getCreditsUsed2();
|
|
11685
|
+
if (used + estimatedCost > MAX_CREDITS_PER_RUN2) {
|
|
11686
|
+
return {
|
|
11687
|
+
ok: false,
|
|
11688
|
+
message: `Credit budget exceeded: ${used} used + ${estimatedCost} estimated > ${MAX_CREDITS_PER_RUN2} limit. Use a smaller slide count or cheaper image model.`
|
|
11689
|
+
};
|
|
11690
|
+
}
|
|
11691
|
+
return { ok: true };
|
|
11692
|
+
}
|
|
11693
|
+
function checkAssetBudget2() {
|
|
11694
|
+
if (MAX_ASSETS_PER_RUN2 <= 0) return { ok: true };
|
|
11695
|
+
const gen = getAssetsGenerated2();
|
|
11696
|
+
if (gen >= MAX_ASSETS_PER_RUN2) {
|
|
11697
|
+
return {
|
|
11698
|
+
ok: false,
|
|
11699
|
+
message: `Asset limit reached: ${gen}/${MAX_ASSETS_PER_RUN2} assets generated this run.`
|
|
11700
|
+
};
|
|
11701
|
+
}
|
|
11702
|
+
return { ok: true };
|
|
11703
|
+
}
|
|
11704
|
+
var IMAGE_CREDIT_ESTIMATES2 = {
|
|
11705
|
+
midjourney: 20,
|
|
11706
|
+
"nano-banana": 15,
|
|
11707
|
+
"nano-banana-pro": 25,
|
|
11708
|
+
"flux-pro": 30,
|
|
11709
|
+
"flux-max": 50,
|
|
11710
|
+
"gpt4o-image": 40,
|
|
11711
|
+
imagen4: 35,
|
|
11712
|
+
"imagen4-fast": 25,
|
|
11713
|
+
seedream: 20
|
|
11714
|
+
};
|
|
11715
|
+
async function fetchBrandVisualContext(projectId) {
|
|
11716
|
+
const { data, error } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
|
|
11717
|
+
if (error || !data?.success || !data.profile?.profile_data) return null;
|
|
11718
|
+
const profile = data.profile.profile_data;
|
|
11719
|
+
const parts = [];
|
|
11720
|
+
const palette = profile.colorPalette;
|
|
11721
|
+
if (palette) {
|
|
11722
|
+
const colors = Object.entries(palette).filter(([, v]) => typeof v === "string" && v.startsWith("#")).map(([k, v]) => `${k}: ${v}`).slice(0, 5);
|
|
11723
|
+
if (colors.length > 0) {
|
|
11724
|
+
parts.push(`Brand color palette: ${colors.join(", ")}`);
|
|
11725
|
+
}
|
|
11726
|
+
}
|
|
11727
|
+
const logoUrl = profile.logoUrl;
|
|
11728
|
+
let logoDesc = null;
|
|
11729
|
+
if (logoUrl) {
|
|
11730
|
+
const brandName = profile.name || "brand";
|
|
11731
|
+
logoDesc = `Include a small "${brandName}" logo watermark in the bottom-right corner`;
|
|
11732
|
+
parts.push(logoDesc);
|
|
11733
|
+
}
|
|
11734
|
+
const voice = profile.voiceProfile;
|
|
11735
|
+
if (voice?.tone && Array.isArray(voice.tone) && voice.tone.length > 0) {
|
|
11736
|
+
parts.push(`Visual mood: ${voice.tone.slice(0, 3).join(", ")}`);
|
|
11737
|
+
}
|
|
11738
|
+
if (parts.length === 0) return null;
|
|
11739
|
+
return {
|
|
11740
|
+
stylePrefix: parts.join(". "),
|
|
11741
|
+
brandName: profile.name || null,
|
|
11742
|
+
logoDescription: logoDesc
|
|
11743
|
+
};
|
|
11744
|
+
}
|
|
11745
|
+
function registerCarouselTools(server) {
|
|
11746
|
+
server.tool(
|
|
11747
|
+
"create_carousel",
|
|
11748
|
+
"End-to-end carousel creation: generates slide text + kicks off image generation for each slide in parallel. When brand_id is provided, auto-injects brand colors, logo watermark, and visual mood into every image prompt. Returns carousel data + image job_ids. Poll each job_id with check_status until complete, then call schedule_post with job_ids to publish as Instagram carousel (media_type=CAROUSEL_ALBUM).",
|
|
11749
|
+
{
|
|
11750
|
+
topic: z27.string().max(200).describe(
|
|
11751
|
+
'Carousel topic/hook \u2014 be specific. Example: "5 pricing mistakes that kill SaaS startups" beats "SaaS tips".'
|
|
11752
|
+
),
|
|
11753
|
+
image_model: z27.enum([
|
|
11754
|
+
"midjourney",
|
|
11755
|
+
"nano-banana",
|
|
11756
|
+
"nano-banana-pro",
|
|
11757
|
+
"flux-pro",
|
|
11758
|
+
"flux-max",
|
|
11759
|
+
"gpt4o-image",
|
|
11760
|
+
"imagen4",
|
|
11761
|
+
"imagen4-fast",
|
|
11762
|
+
"seedream"
|
|
11763
|
+
]).describe(
|
|
11764
|
+
"Image model for slide visuals. flux-pro for general purpose, imagen4 for photorealistic, midjourney for artistic."
|
|
11765
|
+
),
|
|
11766
|
+
template_id: z27.enum([
|
|
11767
|
+
"educational-series",
|
|
11768
|
+
"product-showcase",
|
|
11769
|
+
"story-arc",
|
|
11770
|
+
"before-after",
|
|
11771
|
+
"step-by-step",
|
|
11772
|
+
"quote-collection",
|
|
11773
|
+
"data-stats",
|
|
11774
|
+
"myth-vs-reality",
|
|
11775
|
+
"hormozi-authority"
|
|
11776
|
+
]).optional().describe("Carousel template. Default: hormozi-authority."),
|
|
11777
|
+
slide_count: z27.number().min(3).max(10).optional().describe("Number of slides (3-10). Default: 7."),
|
|
11778
|
+
aspect_ratio: z27.enum(["1:1", "4:5", "9:16"]).optional().describe("Aspect ratio for both carousel and images. Default: 1:1."),
|
|
11779
|
+
style: z27.enum(["minimal", "bold", "professional", "playful", "hormozi"]).optional().describe("Visual style. Default: hormozi for hormozi-authority template."),
|
|
11780
|
+
image_style_suffix: z27.string().max(500).optional().describe(
|
|
11781
|
+
'Style suffix appended to every image prompt for visual consistency across slides. Example: "dark moody lighting, cinematic, 35mm film grain".'
|
|
11782
|
+
),
|
|
11783
|
+
project_id: z27.string().optional().describe("Project ID to associate the carousel with."),
|
|
11784
|
+
response_format: z27.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
11785
|
+
},
|
|
11786
|
+
async ({
|
|
11787
|
+
topic,
|
|
11788
|
+
image_model,
|
|
11789
|
+
template_id,
|
|
11790
|
+
slide_count,
|
|
11791
|
+
aspect_ratio,
|
|
11792
|
+
style,
|
|
11793
|
+
image_style_suffix,
|
|
11794
|
+
brand_id,
|
|
11795
|
+
project_id,
|
|
11796
|
+
response_format
|
|
11797
|
+
}) => {
|
|
11798
|
+
const format = response_format ?? "text";
|
|
11799
|
+
const startedAt = Date.now();
|
|
11800
|
+
const templateId = template_id ?? "hormozi-authority";
|
|
11801
|
+
const resolvedStyle = style ?? (templateId === "hormozi-authority" ? "hormozi" : "professional");
|
|
11802
|
+
const slideCount = slide_count ?? 7;
|
|
11803
|
+
const ratio = aspect_ratio ?? "1:1";
|
|
11804
|
+
let brandContext = null;
|
|
11805
|
+
const brandProjectId = brand_id || project_id || await getDefaultProjectId();
|
|
11806
|
+
if (brandProjectId) {
|
|
11807
|
+
brandContext = await fetchBrandVisualContext(brandProjectId);
|
|
11808
|
+
}
|
|
11809
|
+
const carouselTextCost = 10 + slideCount * 2;
|
|
11810
|
+
const perImageCost = IMAGE_CREDIT_ESTIMATES2[image_model] ?? 30;
|
|
11811
|
+
const totalEstimatedCost = carouselTextCost + slideCount * perImageCost;
|
|
11812
|
+
const budgetCheck = checkCreditBudget2(totalEstimatedCost);
|
|
11813
|
+
if (!budgetCheck.ok) {
|
|
11814
|
+
await logMcpToolInvocation({
|
|
11815
|
+
toolName: "create_carousel",
|
|
11816
|
+
status: "error",
|
|
11817
|
+
durationMs: Date.now() - startedAt,
|
|
11818
|
+
details: { error: budgetCheck.message, totalEstimatedCost }
|
|
11819
|
+
});
|
|
11820
|
+
return {
|
|
11821
|
+
content: [{ type: "text", text: budgetCheck.message }],
|
|
11822
|
+
isError: true
|
|
11823
|
+
};
|
|
11824
|
+
}
|
|
11825
|
+
const assetBudget = checkAssetBudget2();
|
|
11826
|
+
if (!assetBudget.ok) {
|
|
11827
|
+
await logMcpToolInvocation({
|
|
11828
|
+
toolName: "create_carousel",
|
|
11829
|
+
status: "error",
|
|
11830
|
+
durationMs: Date.now() - startedAt,
|
|
11831
|
+
details: { error: assetBudget.message }
|
|
11832
|
+
});
|
|
11833
|
+
return {
|
|
11834
|
+
content: [{ type: "text", text: assetBudget.message }],
|
|
11835
|
+
isError: true
|
|
11836
|
+
};
|
|
11837
|
+
}
|
|
11838
|
+
const userId = await getDefaultUserId();
|
|
11839
|
+
const rateLimit = checkRateLimit("posting", `create_carousel:${userId}`);
|
|
11840
|
+
if (!rateLimit.allowed) {
|
|
11841
|
+
await logMcpToolInvocation({
|
|
11842
|
+
toolName: "create_carousel",
|
|
11843
|
+
status: "rate_limited",
|
|
11844
|
+
durationMs: Date.now() - startedAt,
|
|
11845
|
+
details: { retryAfter: rateLimit.retryAfter }
|
|
11846
|
+
});
|
|
11847
|
+
return {
|
|
11848
|
+
content: [
|
|
11849
|
+
{
|
|
11850
|
+
type: "text",
|
|
11851
|
+
text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
|
|
11852
|
+
}
|
|
11853
|
+
],
|
|
11854
|
+
isError: true
|
|
11855
|
+
};
|
|
11856
|
+
}
|
|
11857
|
+
const { data: carouselData, error: carouselError } = await callEdgeFunction(
|
|
11858
|
+
"generate-carousel",
|
|
11859
|
+
{
|
|
11860
|
+
topic,
|
|
11861
|
+
templateId,
|
|
11862
|
+
slideCount,
|
|
11863
|
+
aspectRatio: ratio,
|
|
11864
|
+
style: resolvedStyle,
|
|
11865
|
+
projectId: project_id
|
|
11866
|
+
},
|
|
11867
|
+
{ timeoutMs: 6e4 }
|
|
11868
|
+
);
|
|
11869
|
+
if (carouselError || !carouselData?.carousel) {
|
|
11870
|
+
const errMsg = carouselError ?? "No carousel data returned";
|
|
11871
|
+
await logMcpToolInvocation({
|
|
11872
|
+
toolName: "create_carousel",
|
|
11873
|
+
status: "error",
|
|
11874
|
+
durationMs: Date.now() - startedAt,
|
|
11875
|
+
details: { phase: "text_generation", error: errMsg }
|
|
11876
|
+
});
|
|
11877
|
+
return {
|
|
11878
|
+
content: [{ type: "text", text: `Carousel text generation failed: ${errMsg}` }],
|
|
11879
|
+
isError: true
|
|
11880
|
+
};
|
|
11881
|
+
}
|
|
11882
|
+
const carousel = carouselData.carousel;
|
|
11883
|
+
const textCredits = carousel.credits?.used ?? carouselTextCost;
|
|
11884
|
+
addCreditsUsed2(textCredits);
|
|
11885
|
+
const imageJobs = await Promise.all(
|
|
11886
|
+
carousel.slides.map(async (slide) => {
|
|
11887
|
+
const promptParts = [];
|
|
11888
|
+
if (brandContext) promptParts.push(brandContext.stylePrefix);
|
|
11889
|
+
if (slide.headline) promptParts.push(slide.headline);
|
|
11890
|
+
if (slide.body) promptParts.push(slide.body);
|
|
11891
|
+
if (promptParts.length === 0) promptParts.push(topic);
|
|
11892
|
+
if (image_style_suffix) promptParts.push(image_style_suffix);
|
|
11893
|
+
const imagePrompt = promptParts.join(". ");
|
|
11894
|
+
try {
|
|
11895
|
+
const { data, error } = await callEdgeFunction(
|
|
11896
|
+
"kie-image-generate",
|
|
11897
|
+
{
|
|
11898
|
+
prompt: imagePrompt,
|
|
11899
|
+
model: image_model,
|
|
11900
|
+
aspectRatio: ratio
|
|
11901
|
+
},
|
|
11902
|
+
{ timeoutMs: 3e4 }
|
|
11903
|
+
);
|
|
11904
|
+
if (error || !data?.taskId && !data?.asyncJobId) {
|
|
11905
|
+
return {
|
|
11906
|
+
slideNumber: slide.slideNumber,
|
|
11907
|
+
jobId: null,
|
|
11908
|
+
model: image_model,
|
|
11909
|
+
error: error ?? "No job ID returned"
|
|
11910
|
+
};
|
|
11911
|
+
}
|
|
11912
|
+
const jobId = data.asyncJobId ?? data.taskId ?? null;
|
|
11913
|
+
if (jobId) {
|
|
11914
|
+
addCreditsUsed2(perImageCost);
|
|
11915
|
+
addAssetsGenerated2(1);
|
|
11916
|
+
}
|
|
11917
|
+
return {
|
|
11918
|
+
slideNumber: slide.slideNumber,
|
|
11919
|
+
jobId,
|
|
11920
|
+
model: image_model,
|
|
11921
|
+
error: null
|
|
11922
|
+
};
|
|
11923
|
+
} catch (err) {
|
|
11924
|
+
return {
|
|
11925
|
+
slideNumber: slide.slideNumber,
|
|
11926
|
+
jobId: null,
|
|
11927
|
+
model: image_model,
|
|
11928
|
+
error: sanitizeError(err)
|
|
11929
|
+
};
|
|
11930
|
+
}
|
|
11931
|
+
})
|
|
11932
|
+
);
|
|
11933
|
+
const successfulJobs = imageJobs.filter((j) => j.jobId !== null);
|
|
11934
|
+
const failedJobs = imageJobs.filter((j) => j.jobId === null);
|
|
11935
|
+
await logMcpToolInvocation({
|
|
11936
|
+
toolName: "create_carousel",
|
|
11937
|
+
status: failedJobs.length === imageJobs.length ? "error" : "success",
|
|
11938
|
+
durationMs: Date.now() - startedAt,
|
|
11939
|
+
details: {
|
|
11940
|
+
carouselId: carousel.id,
|
|
11941
|
+
templateId,
|
|
11942
|
+
slideCount: carousel.slides.length,
|
|
11943
|
+
imagesStarted: successfulJobs.length,
|
|
11944
|
+
imagesFailed: failedJobs.length,
|
|
11945
|
+
imageModel: image_model,
|
|
11946
|
+
creditsUsed: getCreditsUsed2()
|
|
11947
|
+
}
|
|
11948
|
+
});
|
|
11949
|
+
if (format === "json") {
|
|
11950
|
+
return {
|
|
11951
|
+
content: [
|
|
11952
|
+
{
|
|
11953
|
+
type: "text",
|
|
11954
|
+
text: JSON.stringify(
|
|
11955
|
+
{
|
|
11956
|
+
_meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
11957
|
+
data: {
|
|
11958
|
+
carouselId: carousel.id,
|
|
11959
|
+
templateId,
|
|
11960
|
+
style: resolvedStyle,
|
|
11961
|
+
slideCount: carousel.slides.length,
|
|
11962
|
+
slides: carousel.slides.map((s) => {
|
|
11963
|
+
const job = imageJobs.find((j) => j.slideNumber === s.slideNumber);
|
|
11964
|
+
return {
|
|
11965
|
+
...s,
|
|
11966
|
+
imageJobId: job?.jobId ?? null,
|
|
11967
|
+
imageError: job?.error ?? null
|
|
11968
|
+
};
|
|
11969
|
+
}),
|
|
11970
|
+
imageModel: image_model,
|
|
11971
|
+
brandApplied: brandContext ? {
|
|
11972
|
+
brandName: brandContext.brandName,
|
|
11973
|
+
hasLogo: !!brandContext.logoDescription,
|
|
11974
|
+
stylePrefix: brandContext.stylePrefix
|
|
11975
|
+
} : null,
|
|
11976
|
+
jobIds: successfulJobs.map((j) => j.jobId),
|
|
11977
|
+
failedSlides: failedJobs.map((j) => ({
|
|
11978
|
+
slideNumber: j.slideNumber,
|
|
11979
|
+
error: j.error
|
|
11980
|
+
})),
|
|
11981
|
+
credits: {
|
|
11982
|
+
textGeneration: textCredits,
|
|
11983
|
+
imagesEstimated: successfulJobs.length * perImageCost,
|
|
11984
|
+
totalEstimated: textCredits + successfulJobs.length * perImageCost
|
|
11985
|
+
}
|
|
11986
|
+
}
|
|
11987
|
+
},
|
|
11988
|
+
null,
|
|
11989
|
+
2
|
|
11990
|
+
)
|
|
11991
|
+
}
|
|
11992
|
+
]
|
|
11993
|
+
};
|
|
11994
|
+
}
|
|
11995
|
+
const lines = [
|
|
11996
|
+
`Carousel created: ${carousel.slides.length} slides + ${successfulJobs.length} image jobs started.`,
|
|
11997
|
+
` Carousel ID: ${carousel.id}`,
|
|
11998
|
+
` Template: ${templateId} | Style: ${resolvedStyle}`,
|
|
11999
|
+
` Image model: ${image_model}`,
|
|
12000
|
+
` Credits: ~${textCredits + successfulJobs.length * perImageCost} (${textCredits} text + ${successfulJobs.length * perImageCost} images)`
|
|
12001
|
+
];
|
|
12002
|
+
if (brandContext) {
|
|
12003
|
+
lines.push(
|
|
12004
|
+
` Brand: ${brandContext.brandName || "unnamed"}${brandContext.logoDescription ? " (logo overlay via prompt)" : ""}`
|
|
12005
|
+
);
|
|
12006
|
+
}
|
|
12007
|
+
lines.push("", "Slides:");
|
|
12008
|
+
for (const slide of carousel.slides) {
|
|
12009
|
+
const job = imageJobs.find((j) => j.slideNumber === slide.slideNumber);
|
|
12010
|
+
const status = job?.jobId ? `image: ${job.jobId}` : `image FAILED: ${job?.error}`;
|
|
12011
|
+
lines.push(` ${slide.slideNumber}. ${slide.headline || "(no headline)"} [${status}]`);
|
|
12012
|
+
}
|
|
12013
|
+
if (failedJobs.length > 0) {
|
|
12014
|
+
lines.push("");
|
|
12015
|
+
lines.push(
|
|
12016
|
+
`WARNING: ${failedJobs.length}/${imageJobs.length} image generations failed. Use generate_image manually for failed slides.`
|
|
12017
|
+
);
|
|
12018
|
+
}
|
|
12019
|
+
const jobIdList = successfulJobs.map((j) => j.jobId).join(", ");
|
|
12020
|
+
lines.push("");
|
|
12021
|
+
lines.push("Next steps:");
|
|
12022
|
+
lines.push(` 1. Poll each job: check_status with job_id for each of: ${jobIdList}`);
|
|
12023
|
+
lines.push(
|
|
12024
|
+
" 2. When all complete: schedule_post with job_ids=[...] and media_type=CAROUSEL_ALBUM"
|
|
12025
|
+
);
|
|
12026
|
+
return {
|
|
12027
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
12028
|
+
};
|
|
12029
|
+
}
|
|
12030
|
+
);
|
|
12031
|
+
}
|
|
12032
|
+
|
|
10833
12033
|
// src/lib/register-tools.ts
|
|
10834
12034
|
function applyScopeEnforcement(server, scopeResolver) {
|
|
10835
12035
|
const originalTool = server.tool.bind(server);
|
|
@@ -10924,6 +12124,7 @@ function registerAllTools(server, options) {
|
|
|
10924
12124
|
registerLoopSummaryTools(server);
|
|
10925
12125
|
registerUsageTools(server);
|
|
10926
12126
|
registerAutopilotTools(server);
|
|
12127
|
+
registerRecipeTools(server);
|
|
10927
12128
|
registerExtractionTools(server);
|
|
10928
12129
|
registerQualityTools(server);
|
|
10929
12130
|
registerPlanningTools(server);
|
|
@@ -10933,21 +12134,22 @@ function registerAllTools(server, options) {
|
|
|
10933
12134
|
registerSuggestTools(server);
|
|
10934
12135
|
registerDigestTools(server);
|
|
10935
12136
|
registerBrandRuntimeTools(server);
|
|
12137
|
+
registerCarouselTools(server);
|
|
10936
12138
|
applyAnnotations(server);
|
|
10937
12139
|
}
|
|
10938
12140
|
|
|
10939
12141
|
// src/prompts.ts
|
|
10940
|
-
import { z as
|
|
12142
|
+
import { z as z28 } from "zod";
|
|
10941
12143
|
function registerPrompts(server) {
|
|
10942
12144
|
server.prompt(
|
|
10943
12145
|
"create_weekly_content_plan",
|
|
10944
12146
|
"Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
|
|
10945
12147
|
{
|
|
10946
|
-
niche:
|
|
10947
|
-
platforms:
|
|
12148
|
+
niche: z28.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
|
|
12149
|
+
platforms: z28.string().optional().describe(
|
|
10948
12150
|
'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
|
|
10949
12151
|
),
|
|
10950
|
-
tone:
|
|
12152
|
+
tone: z28.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
|
|
10951
12153
|
},
|
|
10952
12154
|
({ niche, platforms, tone }) => {
|
|
10953
12155
|
const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
|
|
@@ -10989,8 +12191,8 @@ After building the plan, use \`save_content_plan\` to save it.`
|
|
|
10989
12191
|
"analyze_top_content",
|
|
10990
12192
|
"Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
|
|
10991
12193
|
{
|
|
10992
|
-
timeframe:
|
|
10993
|
-
platform:
|
|
12194
|
+
timeframe: z28.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
|
|
12195
|
+
platform: z28.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
|
|
10994
12196
|
},
|
|
10995
12197
|
({ timeframe, platform: platform2 }) => {
|
|
10996
12198
|
const period = timeframe || "30 days";
|
|
@@ -11027,10 +12229,10 @@ Format as a clear, actionable performance report.`
|
|
|
11027
12229
|
"repurpose_content",
|
|
11028
12230
|
"Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
|
|
11029
12231
|
{
|
|
11030
|
-
source:
|
|
12232
|
+
source: z28.string().describe(
|
|
11031
12233
|
"The source content to repurpose \u2014 a URL, transcript, or the content text itself"
|
|
11032
12234
|
),
|
|
11033
|
-
target_platforms:
|
|
12235
|
+
target_platforms: z28.string().optional().describe(
|
|
11034
12236
|
'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
|
|
11035
12237
|
)
|
|
11036
12238
|
},
|
|
@@ -11072,9 +12274,9 @@ For each piece, include the platform, format, character count, and suggested pos
|
|
|
11072
12274
|
"setup_brand_voice",
|
|
11073
12275
|
"Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
|
|
11074
12276
|
{
|
|
11075
|
-
brand_name:
|
|
11076
|
-
industry:
|
|
11077
|
-
website:
|
|
12277
|
+
brand_name: z28.string().describe("Your brand or business name"),
|
|
12278
|
+
industry: z28.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
|
|
12279
|
+
website: z28.string().optional().describe("Your website URL for context")
|
|
11078
12280
|
},
|
|
11079
12281
|
({ brand_name, industry, website }) => {
|
|
11080
12282
|
const industryContext = industry ? ` in the ${industry} space` : "";
|
|
@@ -12140,7 +13342,7 @@ app.post("/mcp", authenticateRequest, async (req, res) => {
|
|
|
12140
13342
|
const rawMessage = err instanceof Error ? err.message : "Internal server error";
|
|
12141
13343
|
console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
|
|
12142
13344
|
if (!res.headersSent) {
|
|
12143
|
-
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message:
|
|
13345
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError(err) } });
|
|
12144
13346
|
}
|
|
12145
13347
|
}
|
|
12146
13348
|
});
|
|
@@ -12182,7 +13384,7 @@ app.delete("/mcp", authenticateRequest, async (req, res) => {
|
|
|
12182
13384
|
app.use((err, _req, res, _next) => {
|
|
12183
13385
|
console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
|
|
12184
13386
|
if (!res.headersSent) {
|
|
12185
|
-
res.status(500).json({ error: "internal_error", error_description:
|
|
13387
|
+
res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
|
|
12186
13388
|
}
|
|
12187
13389
|
});
|
|
12188
13390
|
var httpServer = app.listen(PORT, "0.0.0.0", () => {
|