@socialneuron/mcp-server 1.7.4 → 1.7.9
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 +79 -0
- package/README.md +8 -4
- package/dist/http.js +881 -352
- package/dist/index.js +907 -283
- package/package.json +19 -4
- package/tools.lock.json +90 -0
package/dist/http.js
CHANGED
|
@@ -647,7 +647,12 @@ var TOOL_SCOPES = {
|
|
|
647
647
|
suggest_next_content: "mcp:read",
|
|
648
648
|
// mcp:analytics (digest and anomalies are analytics-scoped)
|
|
649
649
|
generate_performance_digest: "mcp:analytics",
|
|
650
|
-
detect_anomalies: "mcp:analytics"
|
|
650
|
+
detect_anomalies: "mcp:analytics",
|
|
651
|
+
// mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
|
|
652
|
+
open_content_calendar: "mcp:read",
|
|
653
|
+
// platform connection deep-link flow
|
|
654
|
+
start_platform_connection: "mcp:distribute",
|
|
655
|
+
wait_for_connection: "mcp:read"
|
|
651
656
|
};
|
|
652
657
|
function hasScope(userScopes, required) {
|
|
653
658
|
if (userScopes.includes(required)) return true;
|
|
@@ -1383,7 +1388,7 @@ init_supabase();
|
|
|
1383
1388
|
init_request_context();
|
|
1384
1389
|
|
|
1385
1390
|
// src/lib/version.ts
|
|
1386
|
-
var MCP_VERSION = "1.7.
|
|
1391
|
+
var MCP_VERSION = "1.7.9";
|
|
1387
1392
|
|
|
1388
1393
|
// src/tools/content.ts
|
|
1389
1394
|
var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
|
|
@@ -2551,7 +2556,7 @@ var ERROR_PATTERNS = [
|
|
|
2551
2556
|
// Generic sensitive patterns (API keys, URLs with secrets)
|
|
2552
2557
|
[/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
|
|
2553
2558
|
];
|
|
2554
|
-
function
|
|
2559
|
+
function sanitizeError(error) {
|
|
2555
2560
|
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
|
2556
2561
|
if (process.env.NODE_ENV !== "production") {
|
|
2557
2562
|
console.error("[Error]", msg);
|
|
@@ -2564,6 +2569,171 @@ function sanitizeError2(error) {
|
|
|
2564
2569
|
return "An unexpected error occurred. Please try again.";
|
|
2565
2570
|
}
|
|
2566
2571
|
|
|
2572
|
+
// src/lib/ssrf.ts
|
|
2573
|
+
var BLOCKED_IP_PATTERNS = [
|
|
2574
|
+
// IPv4 localhost/loopback
|
|
2575
|
+
/^127\./,
|
|
2576
|
+
/^0\./,
|
|
2577
|
+
// IPv4 private ranges (RFC 1918)
|
|
2578
|
+
/^10\./,
|
|
2579
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
2580
|
+
/^192\.168\./,
|
|
2581
|
+
// IPv4 link-local
|
|
2582
|
+
/^169\.254\./,
|
|
2583
|
+
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
2584
|
+
/^169\.254\.169\.254$/,
|
|
2585
|
+
// IPv4 broadcast
|
|
2586
|
+
/^255\./,
|
|
2587
|
+
// Shared address space (RFC 6598)
|
|
2588
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
2589
|
+
];
|
|
2590
|
+
var BLOCKED_IPV6_PATTERNS = [
|
|
2591
|
+
/^::1$/i,
|
|
2592
|
+
// loopback
|
|
2593
|
+
/^::$/i,
|
|
2594
|
+
// unspecified
|
|
2595
|
+
/^fe[89ab][0-9a-f]:/i,
|
|
2596
|
+
// link-local fe80::/10
|
|
2597
|
+
/^fc[0-9a-f]:/i,
|
|
2598
|
+
// unique local fc00::/7
|
|
2599
|
+
/^fd[0-9a-f]:/i,
|
|
2600
|
+
// unique local fc00::/7
|
|
2601
|
+
/^::ffff:127\./i,
|
|
2602
|
+
// IPv4-mapped localhost
|
|
2603
|
+
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
2604
|
+
// IPv4-mapped private
|
|
2605
|
+
];
|
|
2606
|
+
var BLOCKED_HOSTNAMES = [
|
|
2607
|
+
"localhost",
|
|
2608
|
+
"localhost.localdomain",
|
|
2609
|
+
"local",
|
|
2610
|
+
"127.0.0.1",
|
|
2611
|
+
"0.0.0.0",
|
|
2612
|
+
"[::1]",
|
|
2613
|
+
"[::ffff:127.0.0.1]",
|
|
2614
|
+
// Cloud metadata endpoints
|
|
2615
|
+
"metadata.google.internal",
|
|
2616
|
+
"metadata.goog",
|
|
2617
|
+
"instance-data",
|
|
2618
|
+
"instance-data.ec2.internal"
|
|
2619
|
+
];
|
|
2620
|
+
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
2621
|
+
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
2622
|
+
function isBlockedIP(ip) {
|
|
2623
|
+
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
2624
|
+
if (normalized.includes(":")) {
|
|
2625
|
+
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
2626
|
+
}
|
|
2627
|
+
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
2628
|
+
}
|
|
2629
|
+
function isBlockedHostname(hostname) {
|
|
2630
|
+
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
2631
|
+
}
|
|
2632
|
+
function isIPAddress(hostname) {
|
|
2633
|
+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
2634
|
+
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
2635
|
+
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
2636
|
+
}
|
|
2637
|
+
async function validateUrlForSSRF(urlString) {
|
|
2638
|
+
try {
|
|
2639
|
+
const url = new URL(urlString);
|
|
2640
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
2641
|
+
return {
|
|
2642
|
+
isValid: false,
|
|
2643
|
+
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
if (url.username || url.password) {
|
|
2647
|
+
return {
|
|
2648
|
+
isValid: false,
|
|
2649
|
+
error: "URLs with embedded credentials are not allowed."
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
const hostname = url.hostname.toLowerCase();
|
|
2653
|
+
if (isBlockedHostname(hostname)) {
|
|
2654
|
+
return {
|
|
2655
|
+
isValid: false,
|
|
2656
|
+
error: "Access to internal/localhost addresses is not allowed."
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
2660
|
+
return {
|
|
2661
|
+
isValid: false,
|
|
2662
|
+
error: "Access to private/internal IP addresses is not allowed."
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
2666
|
+
if (BLOCKED_PORTS.includes(port)) {
|
|
2667
|
+
return {
|
|
2668
|
+
isValid: false,
|
|
2669
|
+
error: `Access to port ${port} is not allowed.`
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
let resolvedIP;
|
|
2673
|
+
if (!isIPAddress(hostname)) {
|
|
2674
|
+
try {
|
|
2675
|
+
const dns = await import("node:dns");
|
|
2676
|
+
const resolver = new dns.promises.Resolver();
|
|
2677
|
+
const resolvedIPs = [];
|
|
2678
|
+
try {
|
|
2679
|
+
const aRecords = await resolver.resolve4(hostname);
|
|
2680
|
+
resolvedIPs.push(...aRecords);
|
|
2681
|
+
} catch {
|
|
2682
|
+
}
|
|
2683
|
+
try {
|
|
2684
|
+
const aaaaRecords = await resolver.resolve6(hostname);
|
|
2685
|
+
resolvedIPs.push(...aaaaRecords);
|
|
2686
|
+
} catch {
|
|
2687
|
+
}
|
|
2688
|
+
if (resolvedIPs.length === 0) {
|
|
2689
|
+
return {
|
|
2690
|
+
isValid: false,
|
|
2691
|
+
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
for (const ip of resolvedIPs) {
|
|
2695
|
+
if (isBlockedIP(ip)) {
|
|
2696
|
+
return {
|
|
2697
|
+
isValid: false,
|
|
2698
|
+
error: "Hostname resolves to a private/internal IP address."
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
resolvedIP = resolvedIPs[0];
|
|
2703
|
+
} catch {
|
|
2704
|
+
return {
|
|
2705
|
+
isValid: false,
|
|
2706
|
+
error: "DNS resolution failed. Cannot verify hostname safety."
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
|
|
2711
|
+
} catch (error) {
|
|
2712
|
+
return {
|
|
2713
|
+
isValid: false,
|
|
2714
|
+
error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
function quickSSRFCheck(urlString) {
|
|
2719
|
+
try {
|
|
2720
|
+
const url = new URL(urlString);
|
|
2721
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
2722
|
+
return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
|
|
2723
|
+
}
|
|
2724
|
+
if (url.username || url.password) {
|
|
2725
|
+
return { isValid: false, error: "URLs with credentials not allowed" };
|
|
2726
|
+
}
|
|
2727
|
+
const hostname = url.hostname.toLowerCase();
|
|
2728
|
+
if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
2729
|
+
return { isValid: false, error: "Access to internal addresses not allowed" };
|
|
2730
|
+
}
|
|
2731
|
+
return { isValid: true, sanitizedUrl: url.toString() };
|
|
2732
|
+
} catch {
|
|
2733
|
+
return { isValid: false, error: "Invalid URL format" };
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2567
2737
|
// src/tools/distribution.ts
|
|
2568
2738
|
init_supabase();
|
|
2569
2739
|
|
|
@@ -2725,16 +2895,42 @@ function asEnvelope2(data) {
|
|
|
2725
2895
|
data
|
|
2726
2896
|
};
|
|
2727
2897
|
}
|
|
2898
|
+
function isAlreadyR2Signed(url) {
|
|
2899
|
+
try {
|
|
2900
|
+
const u = new URL(url);
|
|
2901
|
+
return u.searchParams.has("X-Amz-Signature");
|
|
2902
|
+
} catch {
|
|
2903
|
+
return false;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
async function rehostExternalUrl(mediaUrl, projectId) {
|
|
2907
|
+
if (isAlreadyR2Signed(mediaUrl)) {
|
|
2908
|
+
return { signedUrl: mediaUrl, r2Key: "" };
|
|
2909
|
+
}
|
|
2910
|
+
const ssrf = quickSSRFCheck(mediaUrl);
|
|
2911
|
+
if (!ssrf.isValid) {
|
|
2912
|
+
return { error: ssrf.error ?? "URL rejected by SSRF check" };
|
|
2913
|
+
}
|
|
2914
|
+
const { data, error } = await callEdgeFunction(
|
|
2915
|
+
"upload-to-r2",
|
|
2916
|
+
{ url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
|
|
2917
|
+
{ timeoutMs: 6e4 }
|
|
2918
|
+
);
|
|
2919
|
+
if (error || !data?.key || !data?.url) {
|
|
2920
|
+
return { error: error ?? "upload-to-r2 returned no key" };
|
|
2921
|
+
}
|
|
2922
|
+
return { signedUrl: data.url, r2Key: data.key };
|
|
2923
|
+
}
|
|
2728
2924
|
function registerDistributionTools(server) {
|
|
2729
2925
|
server.tool(
|
|
2730
2926
|
"schedule_post",
|
|
2731
|
-
'Publish or schedule a post to connected social platforms.
|
|
2927
|
+
'Publish or schedule a post to connected social platforms. ALWAYS call `list_connected_accounts` FIRST \u2014 if the target platform is not connected, call `start_platform_connection` to get a one-time browser deep link the user opens to complete the platform OAuth (this is a one-time setup on socialneuron.com, not another OAuth in Claude). After they approve, call `wait_for_connection` and only then call schedule_post. 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.',
|
|
2732
2928
|
{
|
|
2733
2929
|
media_url: z3.string().optional().describe(
|
|
2734
|
-
"URL of the media file to post.
|
|
2930
|
+
"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."
|
|
2735
2931
|
),
|
|
2736
2932
|
media_urls: z3.array(z3.string()).optional().describe(
|
|
2737
|
-
"Array of 2-10 image URLs for carousel posts.
|
|
2933
|
+
"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."
|
|
2738
2934
|
),
|
|
2739
2935
|
r2_key: z3.string().optional().describe(
|
|
2740
2936
|
"R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
|
|
@@ -2823,7 +3019,10 @@ function registerDistributionTools(server) {
|
|
|
2823
3019
|
),
|
|
2824
3020
|
project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
|
|
2825
3021
|
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
|
|
2826
|
-
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
|
|
3022
|
+
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
|
|
3023
|
+
auto_rehost: z3.boolean().optional().describe(
|
|
3024
|
+
"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."
|
|
3025
|
+
)
|
|
2827
3026
|
},
|
|
2828
3027
|
async ({
|
|
2829
3028
|
media_url,
|
|
@@ -2837,6 +3036,7 @@ function registerDistributionTools(server) {
|
|
|
2837
3036
|
project_id,
|
|
2838
3037
|
response_format,
|
|
2839
3038
|
attribution,
|
|
3039
|
+
auto_rehost,
|
|
2840
3040
|
r2_key,
|
|
2841
3041
|
r2_keys,
|
|
2842
3042
|
job_id,
|
|
@@ -2948,12 +3148,54 @@ function registerDistributionTools(server) {
|
|
|
2948
3148
|
}
|
|
2949
3149
|
resolvedMediaUrls = resolved;
|
|
2950
3150
|
}
|
|
3151
|
+
const shouldRehost = auto_rehost !== false;
|
|
3152
|
+
if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
|
|
3153
|
+
const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
|
|
3154
|
+
if ("error" in rehost) {
|
|
3155
|
+
return {
|
|
3156
|
+
content: [
|
|
3157
|
+
{
|
|
3158
|
+
type: "text",
|
|
3159
|
+
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.`
|
|
3160
|
+
}
|
|
3161
|
+
],
|
|
3162
|
+
isError: true
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
resolvedMediaUrl = rehost.signedUrl;
|
|
3166
|
+
}
|
|
3167
|
+
if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
|
|
3168
|
+
const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
|
|
3169
|
+
if (needsRehost.some(Boolean)) {
|
|
3170
|
+
const rehosted = await Promise.all(
|
|
3171
|
+
resolvedMediaUrls.map(
|
|
3172
|
+
(u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
|
|
3173
|
+
)
|
|
3174
|
+
);
|
|
3175
|
+
const failIdx = rehosted.findIndex((r) => "error" in r);
|
|
3176
|
+
if (failIdx !== -1) {
|
|
3177
|
+
const failed = rehosted[failIdx];
|
|
3178
|
+
return {
|
|
3179
|
+
content: [
|
|
3180
|
+
{
|
|
3181
|
+
type: "text",
|
|
3182
|
+
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.`
|
|
3183
|
+
}
|
|
3184
|
+
],
|
|
3185
|
+
isError: true
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
resolvedMediaUrls = rehosted.map(
|
|
3189
|
+
(r) => r.signedUrl
|
|
3190
|
+
);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
2951
3193
|
} catch (resolveErr) {
|
|
2952
3194
|
return {
|
|
2953
3195
|
content: [
|
|
2954
3196
|
{
|
|
2955
3197
|
type: "text",
|
|
2956
|
-
text: `Failed to resolve media: ${
|
|
3198
|
+
text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
|
|
2957
3199
|
}
|
|
2958
3200
|
],
|
|
2959
3201
|
isError: true
|
|
@@ -3318,7 +3560,7 @@ Created with Social Neuron`;
|
|
|
3318
3560
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
|
|
3319
3561
|
} catch (err) {
|
|
3320
3562
|
const durationMs = Date.now() - startedAt;
|
|
3321
|
-
const message =
|
|
3563
|
+
const message = sanitizeError(err);
|
|
3322
3564
|
logMcpToolInvocation({
|
|
3323
3565
|
toolName: "find_next_slots",
|
|
3324
3566
|
status: "error",
|
|
@@ -3838,7 +4080,7 @@ Created with Social Neuron`;
|
|
|
3838
4080
|
};
|
|
3839
4081
|
} catch (err) {
|
|
3840
4082
|
const durationMs = Date.now() - startedAt;
|
|
3841
|
-
const message =
|
|
4083
|
+
const message = sanitizeError(err);
|
|
3842
4084
|
logMcpToolInvocation({
|
|
3843
4085
|
toolName: "schedule_content_plan",
|
|
3844
4086
|
status: "error",
|
|
@@ -3860,6 +4102,19 @@ import { readFile } from "node:fs/promises";
|
|
|
3860
4102
|
import { basename, extname } from "node:path";
|
|
3861
4103
|
init_supabase();
|
|
3862
4104
|
var MAX_BASE64_SIZE = 10 * 1024 * 1024;
|
|
4105
|
+
var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
4106
|
+
"image/png",
|
|
4107
|
+
"image/jpeg",
|
|
4108
|
+
"image/gif",
|
|
4109
|
+
"image/webp",
|
|
4110
|
+
"image/svg+xml",
|
|
4111
|
+
"video/mp4",
|
|
4112
|
+
"video/quicktime",
|
|
4113
|
+
"video/x-msvideo",
|
|
4114
|
+
"video/webm"
|
|
4115
|
+
]);
|
|
4116
|
+
var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
4117
|
+
var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
|
|
3863
4118
|
function maskR2Key(key) {
|
|
3864
4119
|
const segments = key.split("/");
|
|
3865
4120
|
return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
|
|
@@ -3880,21 +4135,35 @@ function inferContentType(filePath) {
|
|
|
3880
4135
|
};
|
|
3881
4136
|
return map[ext] || "application/octet-stream";
|
|
3882
4137
|
}
|
|
4138
|
+
function approxBase64Size(raw) {
|
|
4139
|
+
const len = raw.length;
|
|
4140
|
+
if (len === 0) return 0;
|
|
4141
|
+
let padding = 0;
|
|
4142
|
+
if (raw.endsWith("==")) padding = 2;
|
|
4143
|
+
else if (raw.endsWith("=")) padding = 1;
|
|
4144
|
+
return Math.floor(len * 3 / 4) - padding;
|
|
4145
|
+
}
|
|
3883
4146
|
function registerMediaTools(server) {
|
|
3884
4147
|
server.tool(
|
|
3885
4148
|
"upload_media",
|
|
3886
|
-
"Upload
|
|
4149
|
+
"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 (remote agents, \u226410MB decoded). AGENT ROUTING GUIDE: If the media was produced by another tool here (generate_image, generate_video, create_carousel, etc.), use the returned job_id or r2_key directly with schedule_post \u2014 do NOT download and re-upload. For user-authored files larger than ~1MB, prefer request_upload_session (returns a tokenized Dashboard URL the user uploads through in their browser) so bytes never flow through the agent context. Reserve file_data for small assets (thumbnails, logos, short clips).",
|
|
3887
4150
|
{
|
|
3888
|
-
source: z4.string().describe(
|
|
3889
|
-
'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg").
|
|
4151
|
+
source: z4.string().optional().describe(
|
|
4152
|
+
'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.'
|
|
4153
|
+
),
|
|
4154
|
+
file_data: z4.string().optional().describe(
|
|
4155
|
+
'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.'
|
|
4156
|
+
),
|
|
4157
|
+
file_name: z4.string().optional().describe(
|
|
4158
|
+
'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
|
|
3890
4159
|
),
|
|
3891
4160
|
content_type: z4.string().optional().describe(
|
|
3892
|
-
'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension
|
|
4161
|
+
'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.'
|
|
3893
4162
|
),
|
|
3894
4163
|
project_id: z4.string().optional().describe("Project ID for R2 path organization."),
|
|
3895
4164
|
response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
3896
4165
|
},
|
|
3897
|
-
async ({ source, content_type, project_id, response_format }) => {
|
|
4166
|
+
async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
|
|
3898
4167
|
const format = response_format ?? "text";
|
|
3899
4168
|
const startedAt = Date.now();
|
|
3900
4169
|
const userId = await getDefaultUserId();
|
|
@@ -3916,46 +4185,187 @@ function registerMediaTools(server) {
|
|
|
3916
4185
|
isError: true
|
|
3917
4186
|
};
|
|
3918
4187
|
}
|
|
3919
|
-
|
|
3920
|
-
|
|
4188
|
+
if (!source && !file_data) {
|
|
4189
|
+
return {
|
|
4190
|
+
content: [
|
|
4191
|
+
{
|
|
4192
|
+
type: "text",
|
|
4193
|
+
text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
|
|
4194
|
+
}
|
|
4195
|
+
],
|
|
4196
|
+
isError: true
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
if (file_data) {
|
|
4200
|
+
let raw = file_data;
|
|
4201
|
+
let detectedType;
|
|
4202
|
+
const prefixMatch = raw.match(DATA_URI_PREFIX);
|
|
4203
|
+
if (prefixMatch) {
|
|
4204
|
+
detectedType = prefixMatch[1].trim().toLowerCase();
|
|
4205
|
+
raw = raw.slice(prefixMatch[0].length);
|
|
4206
|
+
}
|
|
4207
|
+
const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
|
|
4208
|
+
if (!ct) {
|
|
4209
|
+
return {
|
|
4210
|
+
content: [
|
|
4211
|
+
{
|
|
4212
|
+
type: "text",
|
|
4213
|
+
text: "content_type is required when file_data has no data: prefix."
|
|
4214
|
+
}
|
|
4215
|
+
],
|
|
4216
|
+
isError: true
|
|
4217
|
+
};
|
|
4218
|
+
}
|
|
4219
|
+
if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
|
|
4220
|
+
return {
|
|
4221
|
+
content: [
|
|
4222
|
+
{
|
|
4223
|
+
type: "text",
|
|
4224
|
+
text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
|
|
4225
|
+
}
|
|
4226
|
+
],
|
|
4227
|
+
isError: true
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
const stripped = raw.replace(/\s+/g, "");
|
|
4231
|
+
if (!BASE64_CHARS.test(stripped)) {
|
|
4232
|
+
return {
|
|
4233
|
+
content: [
|
|
4234
|
+
{
|
|
4235
|
+
type: "text",
|
|
4236
|
+
text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
|
|
4237
|
+
}
|
|
4238
|
+
],
|
|
4239
|
+
isError: true
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
const approxSize = approxBase64Size(stripped);
|
|
4243
|
+
if (approxSize > MAX_BASE64_SIZE) {
|
|
4244
|
+
return {
|
|
4245
|
+
content: [
|
|
4246
|
+
{
|
|
4247
|
+
type: "text",
|
|
4248
|
+
text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). Alternatives, in order of preference: (1) if this media came from another tool here (generate_image/video, create_carousel), pass its job_id or r2_key directly to schedule_post \u2014 do not re-upload. (2) for user-authored files, call request_upload_session to get a tokenized Dashboard URL where the user uploads directly to R2 in their browser. (3) for stdio/local mode, pass a filesystem path via \`source\` so the server can stream and use presigned PUT.`
|
|
4249
|
+
}
|
|
4250
|
+
],
|
|
4251
|
+
isError: true
|
|
4252
|
+
};
|
|
4253
|
+
}
|
|
4254
|
+
const safeName = basename(file_name ?? "upload");
|
|
4255
|
+
const uploadBody2 = {
|
|
4256
|
+
fileData: `data:${ct};base64,${stripped}`,
|
|
4257
|
+
contentType: ct,
|
|
4258
|
+
fileName: safeName,
|
|
4259
|
+
projectId: project_id
|
|
4260
|
+
};
|
|
4261
|
+
const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
|
|
4262
|
+
if (error2) {
|
|
4263
|
+
await logMcpToolInvocation({
|
|
4264
|
+
toolName: "upload_media",
|
|
4265
|
+
status: "error",
|
|
4266
|
+
durationMs: Date.now() - startedAt,
|
|
4267
|
+
details: { error: error2, source: "base64", contentType: ct, size: approxSize }
|
|
4268
|
+
});
|
|
4269
|
+
return {
|
|
4270
|
+
content: [{ type: "text", text: `Upload failed: ${error2}` }],
|
|
4271
|
+
isError: true
|
|
4272
|
+
};
|
|
4273
|
+
}
|
|
4274
|
+
if (!data2?.key) {
|
|
4275
|
+
return {
|
|
4276
|
+
content: [{ type: "text", text: "Upload returned no R2 key." }],
|
|
4277
|
+
isError: true
|
|
4278
|
+
};
|
|
4279
|
+
}
|
|
4280
|
+
await logMcpToolInvocation({
|
|
4281
|
+
toolName: "upload_media",
|
|
4282
|
+
status: "success",
|
|
4283
|
+
durationMs: Date.now() - startedAt,
|
|
4284
|
+
details: {
|
|
4285
|
+
source: "base64",
|
|
4286
|
+
r2Key: data2.key,
|
|
4287
|
+
size: data2.size,
|
|
4288
|
+
contentType: data2.contentType
|
|
4289
|
+
}
|
|
4290
|
+
});
|
|
4291
|
+
if (format === "json") {
|
|
4292
|
+
return {
|
|
4293
|
+
content: [
|
|
4294
|
+
{
|
|
4295
|
+
type: "text",
|
|
4296
|
+
text: JSON.stringify(
|
|
4297
|
+
{
|
|
4298
|
+
r2_key: data2.key,
|
|
4299
|
+
signed_url: data2.url,
|
|
4300
|
+
size: data2.size,
|
|
4301
|
+
content_type: data2.contentType
|
|
4302
|
+
},
|
|
4303
|
+
null,
|
|
4304
|
+
2
|
|
4305
|
+
)
|
|
4306
|
+
}
|
|
4307
|
+
],
|
|
4308
|
+
isError: false
|
|
4309
|
+
};
|
|
4310
|
+
}
|
|
4311
|
+
return {
|
|
4312
|
+
content: [
|
|
4313
|
+
{
|
|
4314
|
+
type: "text",
|
|
4315
|
+
text: [
|
|
4316
|
+
"Media uploaded successfully.",
|
|
4317
|
+
`Media key: ${maskR2Key(data2.key)}`,
|
|
4318
|
+
`Signed URL: ${data2.url}`,
|
|
4319
|
+
`Size: ${(data2.size / 1024).toFixed(0)}KB`,
|
|
4320
|
+
`Type: ${data2.contentType}`,
|
|
4321
|
+
"",
|
|
4322
|
+
"Use job_id or response_format=json with schedule_post to post to any platform."
|
|
4323
|
+
].join("\n")
|
|
4324
|
+
}
|
|
4325
|
+
],
|
|
4326
|
+
isError: false
|
|
4327
|
+
};
|
|
4328
|
+
}
|
|
4329
|
+
const src = source;
|
|
4330
|
+
const isUrl = src.startsWith("http://") || src.startsWith("https://");
|
|
3921
4331
|
let uploadBody;
|
|
3922
4332
|
if (isUrl) {
|
|
3923
|
-
const ct = content_type || inferContentType(
|
|
4333
|
+
const ct = content_type || inferContentType(src);
|
|
3924
4334
|
uploadBody = {
|
|
3925
|
-
url:
|
|
4335
|
+
url: src,
|
|
3926
4336
|
contentType: ct,
|
|
3927
|
-
fileName: basename(new URL(
|
|
4337
|
+
fileName: basename(file_name ?? new URL(src).pathname) || "upload",
|
|
3928
4338
|
projectId: project_id
|
|
3929
4339
|
};
|
|
3930
4340
|
} else {
|
|
3931
4341
|
let fileBuffer;
|
|
3932
4342
|
try {
|
|
3933
|
-
fileBuffer = await readFile(
|
|
3934
|
-
} catch
|
|
4343
|
+
fileBuffer = await readFile(src);
|
|
4344
|
+
} catch {
|
|
3935
4345
|
await logMcpToolInvocation({
|
|
3936
4346
|
toolName: "upload_media",
|
|
3937
4347
|
status: "error",
|
|
3938
4348
|
durationMs: Date.now() - startedAt,
|
|
3939
|
-
details: { error:
|
|
4349
|
+
details: { error: "File not found", source: "local" }
|
|
3940
4350
|
});
|
|
3941
4351
|
return {
|
|
3942
4352
|
content: [
|
|
3943
4353
|
{
|
|
3944
4354
|
type: "text",
|
|
3945
|
-
text: `File not found or not readable: ${
|
|
4355
|
+
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.`
|
|
3946
4356
|
}
|
|
3947
4357
|
],
|
|
3948
4358
|
isError: true
|
|
3949
4359
|
};
|
|
3950
4360
|
}
|
|
3951
|
-
const ct = content_type || inferContentType(
|
|
4361
|
+
const ct = content_type || inferContentType(src);
|
|
3952
4362
|
if (fileBuffer.length > MAX_BASE64_SIZE) {
|
|
3953
4363
|
const { data: putData, error: putError } = await callEdgeFunction(
|
|
3954
4364
|
"get-signed-url",
|
|
3955
4365
|
{
|
|
3956
4366
|
operation: "put",
|
|
3957
4367
|
contentType: ct,
|
|
3958
|
-
filename: basename(
|
|
4368
|
+
filename: basename(file_name ?? src),
|
|
3959
4369
|
projectId: project_id
|
|
3960
4370
|
},
|
|
3961
4371
|
{ timeoutMs: 1e4 }
|
|
@@ -4057,7 +4467,7 @@ function registerMediaTools(server) {
|
|
|
4057
4467
|
uploadBody = {
|
|
4058
4468
|
fileData: base64,
|
|
4059
4469
|
contentType: ct,
|
|
4060
|
-
fileName: basename(
|
|
4470
|
+
fileName: basename(file_name ?? src),
|
|
4061
4471
|
projectId: project_id
|
|
4062
4472
|
};
|
|
4063
4473
|
}
|
|
@@ -4429,172 +4839,23 @@ function formatAnalytics(summary, days, format) {
|
|
|
4429
4839
|
lines.push("Top Posts:");
|
|
4430
4840
|
const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
|
|
4431
4841
|
for (const post of sorted.slice(0, 10)) {
|
|
4432
|
-
const title = post.title || "(untitled)";
|
|
4433
|
-
let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
|
|
4434
|
-
if (post.content_type || post.model_used) {
|
|
4435
|
-
const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
|
|
4436
|
-
line += ` (${meta})`;
|
|
4437
|
-
}
|
|
4438
|
-
lines.push(line);
|
|
4439
|
-
}
|
|
4440
|
-
}
|
|
4441
|
-
return {
|
|
4442
|
-
content: [{ type: "text", text: lines.join("\n") }]
|
|
4443
|
-
};
|
|
4444
|
-
}
|
|
4445
|
-
|
|
4446
|
-
// src/tools/brand.ts
|
|
4447
|
-
import { z as z6 } from "zod";
|
|
4448
|
-
init_supabase();
|
|
4449
|
-
|
|
4450
|
-
// src/lib/ssrf.ts
|
|
4451
|
-
var BLOCKED_IP_PATTERNS = [
|
|
4452
|
-
// IPv4 localhost/loopback
|
|
4453
|
-
/^127\./,
|
|
4454
|
-
/^0\./,
|
|
4455
|
-
// IPv4 private ranges (RFC 1918)
|
|
4456
|
-
/^10\./,
|
|
4457
|
-
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
4458
|
-
/^192\.168\./,
|
|
4459
|
-
// IPv4 link-local
|
|
4460
|
-
/^169\.254\./,
|
|
4461
|
-
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
4462
|
-
/^169\.254\.169\.254$/,
|
|
4463
|
-
// IPv4 broadcast
|
|
4464
|
-
/^255\./,
|
|
4465
|
-
// Shared address space (RFC 6598)
|
|
4466
|
-
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
4467
|
-
];
|
|
4468
|
-
var BLOCKED_IPV6_PATTERNS = [
|
|
4469
|
-
/^::1$/i,
|
|
4470
|
-
// loopback
|
|
4471
|
-
/^::$/i,
|
|
4472
|
-
// unspecified
|
|
4473
|
-
/^fe[89ab][0-9a-f]:/i,
|
|
4474
|
-
// link-local fe80::/10
|
|
4475
|
-
/^fc[0-9a-f]:/i,
|
|
4476
|
-
// unique local fc00::/7
|
|
4477
|
-
/^fd[0-9a-f]:/i,
|
|
4478
|
-
// unique local fc00::/7
|
|
4479
|
-
/^::ffff:127\./i,
|
|
4480
|
-
// IPv4-mapped localhost
|
|
4481
|
-
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
4482
|
-
// IPv4-mapped private
|
|
4483
|
-
];
|
|
4484
|
-
var BLOCKED_HOSTNAMES = [
|
|
4485
|
-
"localhost",
|
|
4486
|
-
"localhost.localdomain",
|
|
4487
|
-
"local",
|
|
4488
|
-
"127.0.0.1",
|
|
4489
|
-
"0.0.0.0",
|
|
4490
|
-
"[::1]",
|
|
4491
|
-
"[::ffff:127.0.0.1]",
|
|
4492
|
-
// Cloud metadata endpoints
|
|
4493
|
-
"metadata.google.internal",
|
|
4494
|
-
"metadata.goog",
|
|
4495
|
-
"instance-data",
|
|
4496
|
-
"instance-data.ec2.internal"
|
|
4497
|
-
];
|
|
4498
|
-
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
4499
|
-
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
4500
|
-
function isBlockedIP(ip) {
|
|
4501
|
-
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
4502
|
-
if (normalized.includes(":")) {
|
|
4503
|
-
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
4504
|
-
}
|
|
4505
|
-
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
4506
|
-
}
|
|
4507
|
-
function isBlockedHostname(hostname) {
|
|
4508
|
-
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
4509
|
-
}
|
|
4510
|
-
function isIPAddress(hostname) {
|
|
4511
|
-
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
4512
|
-
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
4513
|
-
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
4514
|
-
}
|
|
4515
|
-
async function validateUrlForSSRF(urlString) {
|
|
4516
|
-
try {
|
|
4517
|
-
const url = new URL(urlString);
|
|
4518
|
-
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
4519
|
-
return {
|
|
4520
|
-
isValid: false,
|
|
4521
|
-
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
4522
|
-
};
|
|
4523
|
-
}
|
|
4524
|
-
if (url.username || url.password) {
|
|
4525
|
-
return {
|
|
4526
|
-
isValid: false,
|
|
4527
|
-
error: "URLs with embedded credentials are not allowed."
|
|
4528
|
-
};
|
|
4529
|
-
}
|
|
4530
|
-
const hostname = url.hostname.toLowerCase();
|
|
4531
|
-
if (isBlockedHostname(hostname)) {
|
|
4532
|
-
return {
|
|
4533
|
-
isValid: false,
|
|
4534
|
-
error: "Access to internal/localhost addresses is not allowed."
|
|
4535
|
-
};
|
|
4536
|
-
}
|
|
4537
|
-
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
4538
|
-
return {
|
|
4539
|
-
isValid: false,
|
|
4540
|
-
error: "Access to private/internal IP addresses is not allowed."
|
|
4541
|
-
};
|
|
4542
|
-
}
|
|
4543
|
-
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
4544
|
-
if (BLOCKED_PORTS.includes(port)) {
|
|
4545
|
-
return {
|
|
4546
|
-
isValid: false,
|
|
4547
|
-
error: `Access to port ${port} is not allowed.`
|
|
4548
|
-
};
|
|
4549
|
-
}
|
|
4550
|
-
let resolvedIP;
|
|
4551
|
-
if (!isIPAddress(hostname)) {
|
|
4552
|
-
try {
|
|
4553
|
-
const dns = await import("node:dns");
|
|
4554
|
-
const resolver = new dns.promises.Resolver();
|
|
4555
|
-
const resolvedIPs = [];
|
|
4556
|
-
try {
|
|
4557
|
-
const aRecords = await resolver.resolve4(hostname);
|
|
4558
|
-
resolvedIPs.push(...aRecords);
|
|
4559
|
-
} catch {
|
|
4560
|
-
}
|
|
4561
|
-
try {
|
|
4562
|
-
const aaaaRecords = await resolver.resolve6(hostname);
|
|
4563
|
-
resolvedIPs.push(...aaaaRecords);
|
|
4564
|
-
} catch {
|
|
4565
|
-
}
|
|
4566
|
-
if (resolvedIPs.length === 0) {
|
|
4567
|
-
return {
|
|
4568
|
-
isValid: false,
|
|
4569
|
-
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
4570
|
-
};
|
|
4571
|
-
}
|
|
4572
|
-
for (const ip of resolvedIPs) {
|
|
4573
|
-
if (isBlockedIP(ip)) {
|
|
4574
|
-
return {
|
|
4575
|
-
isValid: false,
|
|
4576
|
-
error: "Hostname resolves to a private/internal IP address."
|
|
4577
|
-
};
|
|
4578
|
-
}
|
|
4579
|
-
}
|
|
4580
|
-
resolvedIP = resolvedIPs[0];
|
|
4581
|
-
} catch {
|
|
4582
|
-
return {
|
|
4583
|
-
isValid: false,
|
|
4584
|
-
error: "DNS resolution failed. Cannot verify hostname safety."
|
|
4585
|
-
};
|
|
4842
|
+
const title = post.title || "(untitled)";
|
|
4843
|
+
let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
|
|
4844
|
+
if (post.content_type || post.model_used) {
|
|
4845
|
+
const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
|
|
4846
|
+
line += ` (${meta})`;
|
|
4586
4847
|
}
|
|
4848
|
+
lines.push(line);
|
|
4587
4849
|
}
|
|
4588
|
-
return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
|
|
4589
|
-
} catch (error) {
|
|
4590
|
-
return {
|
|
4591
|
-
isValid: false,
|
|
4592
|
-
error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
4593
|
-
};
|
|
4594
4850
|
}
|
|
4851
|
+
return {
|
|
4852
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
4853
|
+
};
|
|
4595
4854
|
}
|
|
4596
4855
|
|
|
4597
4856
|
// src/tools/brand.ts
|
|
4857
|
+
import { z as z6 } from "zod";
|
|
4858
|
+
init_supabase();
|
|
4598
4859
|
function asEnvelope4(data) {
|
|
4599
4860
|
return {
|
|
4600
4861
|
_meta: {
|
|
@@ -4743,7 +5004,7 @@ function registerBrandTools(server) {
|
|
|
4743
5004
|
);
|
|
4744
5005
|
server.tool(
|
|
4745
5006
|
"save_brand_profile",
|
|
4746
|
-
"
|
|
5007
|
+
"Save (or replace) the active brand profile for a project \u2014 voice, target audience, content pillars, claims, etc. Use after extract_brand has produced a draft AND the user has reviewed it, or when the user explicitly edits the profile. brand_context is the full profile payload from extract_brand or get_brand_profile. project_id defaults to the active project context. Overwrites the previous active profile (one per project) \u2014 pass the complete profile, no merge semantics. Use change_summary to leave an audit trail.",
|
|
4747
5008
|
{
|
|
4748
5009
|
project_id: z6.string().uuid().optional().describe("Project ID. Defaults to active project context."),
|
|
4749
5010
|
brand_context: z6.record(z6.string(), z6.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
|
|
@@ -5117,7 +5378,7 @@ function registerScreenshotTools(server) {
|
|
|
5117
5378
|
};
|
|
5118
5379
|
} catch (err) {
|
|
5119
5380
|
await closeBrowser();
|
|
5120
|
-
const message =
|
|
5381
|
+
const message = sanitizeError(err);
|
|
5121
5382
|
await logMcpToolInvocation({
|
|
5122
5383
|
toolName: "capture_app_page",
|
|
5123
5384
|
status: "error",
|
|
@@ -5273,7 +5534,7 @@ function registerScreenshotTools(server) {
|
|
|
5273
5534
|
};
|
|
5274
5535
|
} catch (err) {
|
|
5275
5536
|
await closeBrowser();
|
|
5276
|
-
const message =
|
|
5537
|
+
const message = sanitizeError(err);
|
|
5277
5538
|
await logMcpToolInvocation({
|
|
5278
5539
|
toolName: "capture_screenshot",
|
|
5279
5540
|
status: "error",
|
|
@@ -5566,7 +5827,7 @@ function registerRemotionTools(server) {
|
|
|
5566
5827
|
]
|
|
5567
5828
|
};
|
|
5568
5829
|
} catch (err) {
|
|
5569
|
-
const message =
|
|
5830
|
+
const message = sanitizeError(err);
|
|
5570
5831
|
await logMcpToolInvocation({
|
|
5571
5832
|
toolName: "render_demo_video",
|
|
5572
5833
|
status: "error",
|
|
@@ -5694,7 +5955,7 @@ function registerRemotionTools(server) {
|
|
|
5694
5955
|
]
|
|
5695
5956
|
};
|
|
5696
5957
|
} catch (err) {
|
|
5697
|
-
const message =
|
|
5958
|
+
const message = sanitizeError(err);
|
|
5698
5959
|
await logMcpToolInvocation({
|
|
5699
5960
|
toolName: "render_template_video",
|
|
5700
5961
|
status: "error",
|
|
@@ -6321,7 +6582,7 @@ function registerCommentsTools(server) {
|
|
|
6321
6582
|
);
|
|
6322
6583
|
server.tool(
|
|
6323
6584
|
"post_comment",
|
|
6324
|
-
"Post a new top-level comment on a YouTube video.",
|
|
6585
|
+
"Post a new top-level comment on a YouTube video, authored as the connected channel. Use for proactive engagement on your own videos. For replies to existing comments use reply_to_comment instead \u2014 this tool only creates top-level comments. video_id comes from list_recent_posts (platform_post_id field) or any YouTube URL (the v= parameter, 11 chars). Subject to YouTube anti-spam rate limits; calls return rate_limited if exceeded.",
|
|
6325
6586
|
{
|
|
6326
6587
|
video_id: z11.string().describe("The YouTube video ID to comment on."),
|
|
6327
6588
|
text: z11.string().min(1).describe("The comment text."),
|
|
@@ -6392,7 +6653,7 @@ function registerCommentsTools(server) {
|
|
|
6392
6653
|
);
|
|
6393
6654
|
server.tool(
|
|
6394
6655
|
"moderate_comment",
|
|
6395
|
-
|
|
6656
|
+
'Moderate a YouTube comment on your channel \u2014 set status to "published" (approve) or "rejected" (hide from public view but kept in moderation queue). Use after list_comments surfaces a comment that needs action. For permanent removal use delete_comment instead. comment_id comes from list_comments results.',
|
|
6396
6657
|
{
|
|
6397
6658
|
comment_id: z11.string().describe("The comment ID to moderate."),
|
|
6398
6659
|
moderation_status: z11.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
|
|
@@ -7611,7 +7872,7 @@ function registerExtractionTools(server) {
|
|
|
7611
7872
|
};
|
|
7612
7873
|
} catch (err) {
|
|
7613
7874
|
const durationMs = Date.now() - startedAt;
|
|
7614
|
-
const message =
|
|
7875
|
+
const message = sanitizeError(err);
|
|
7615
7876
|
logMcpToolInvocation({
|
|
7616
7877
|
toolName: "extract_url_content",
|
|
7617
7878
|
status: "error",
|
|
@@ -8167,7 +8428,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8167
8428
|
}
|
|
8168
8429
|
} catch (persistErr) {
|
|
8169
8430
|
const durationMs2 = Date.now() - startedAt;
|
|
8170
|
-
const message =
|
|
8431
|
+
const message = sanitizeError(persistErr);
|
|
8171
8432
|
logMcpToolInvocation({
|
|
8172
8433
|
toolName: "plan_content_week",
|
|
8173
8434
|
status: "error",
|
|
@@ -8199,7 +8460,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8199
8460
|
};
|
|
8200
8461
|
} catch (err) {
|
|
8201
8462
|
const durationMs = Date.now() - startedAt;
|
|
8202
|
-
const message =
|
|
8463
|
+
const message = sanitizeError(err);
|
|
8203
8464
|
logMcpToolInvocation({
|
|
8204
8465
|
toolName: "plan_content_week",
|
|
8205
8466
|
status: "error",
|
|
@@ -8291,7 +8552,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8291
8552
|
};
|
|
8292
8553
|
} catch (err) {
|
|
8293
8554
|
const durationMs = Date.now() - startedAt;
|
|
8294
|
-
const message =
|
|
8555
|
+
const message = sanitizeError(err);
|
|
8295
8556
|
logMcpToolInvocation({
|
|
8296
8557
|
toolName: "save_content_plan",
|
|
8297
8558
|
status: "error",
|
|
@@ -8307,7 +8568,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8307
8568
|
);
|
|
8308
8569
|
server.tool(
|
|
8309
8570
|
"get_content_plan",
|
|
8310
|
-
"
|
|
8571
|
+
"Load a persisted content plan by its UUID \u2014 returns the full plan including all posts, scheduling status, and approval state. Use to inspect a plan before update_content_plan or schedule_content_plan. plan_id comes from save_content_plan, plan_content_week (when persisted), or list_plan_approvals. For just the approval state, list_plan_approvals is cheaper.",
|
|
8311
8572
|
{
|
|
8312
8573
|
plan_id: z20.string().uuid().describe("Persisted content plan ID"),
|
|
8313
8574
|
response_format: z20.enum(["text", "json"]).default("json")
|
|
@@ -8361,7 +8622,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
8361
8622
|
);
|
|
8362
8623
|
server.tool(
|
|
8363
8624
|
"update_content_plan",
|
|
8364
|
-
"
|
|
8625
|
+
"Edit individual posts in a persisted content plan \u2014 change caption, title, hashtags, hook, or angle. Use after get_content_plan when the user wants to revise drafts before scheduling. Each post_updates entry must include post_id from the loaded plan; only the fields you pass get updated, others stay as-is. Does NOT trigger publishing \u2014 call schedule_content_plan separately when ready.",
|
|
8365
8626
|
{
|
|
8366
8627
|
plan_id: z20.string().uuid(),
|
|
8367
8628
|
post_updates: z20.array(
|
|
@@ -8502,7 +8763,7 @@ function asEnvelope17(data) {
|
|
|
8502
8763
|
function registerPlanApprovalTools(server) {
|
|
8503
8764
|
server.tool(
|
|
8504
8765
|
"create_plan_approvals",
|
|
8505
|
-
|
|
8766
|
+
'Create pending approval rows for each post in a content plan \u2014 one row per post, status="pending". Use after submit_content_plan_for_approval to materialize the approval queue. Each entry in posts becomes a row that respond_plan_approval can later approve, reject, or edit. Idempotent on (plan_id, post_id) \u2014 calling twice with the same posts is a no-op for already-existing rows. Returns IDs of created items for use with list_plan_approvals.',
|
|
8506
8767
|
{
|
|
8507
8768
|
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
8508
8769
|
posts: z21.array(
|
|
@@ -8582,7 +8843,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8582
8843
|
);
|
|
8583
8844
|
server.tool(
|
|
8584
8845
|
"list_plan_approvals",
|
|
8585
|
-
"List
|
|
8846
|
+
"List approval items for a content plan, optionally filtered by status (pending / approved / rejected / edited). Use to check what needs review before scheduling, or to audit decisions after the fact. plan_id comes from get_content_plan or save_content_plan. For a single item's full state, get the plan via get_content_plan instead \u2014 that includes per-post approval data inline.",
|
|
8586
8847
|
{
|
|
8587
8848
|
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
8588
8849
|
status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
|
|
@@ -8642,7 +8903,7 @@ function registerPlanApprovalTools(server) {
|
|
|
8642
8903
|
);
|
|
8643
8904
|
server.tool(
|
|
8644
8905
|
"respond_plan_approval",
|
|
8645
|
-
|
|
8906
|
+
'Approve, reject, or edit a single pending plan approval item. Use to act on items surfaced by list_plan_approvals. decision="edited" REQUIRES edited_post containing the modified post fields \u2014 passing "edited" without edited_post returns an error. Once decided, an item cannot be re-decided (immutable transition). reason is optional but recommended for "rejected" or "edited" to leave a paper trail. After all items are decided, schedule_content_plan publishes only the approved (and edited) ones.',
|
|
8646
8907
|
{
|
|
8647
8908
|
approval_id: z21.string().uuid().describe("Approval item ID"),
|
|
8648
8909
|
decision: z21.enum(["approved", "rejected", "edited"]),
|
|
@@ -9158,6 +9419,13 @@ var TOOL_CATALOG = [
|
|
|
9158
9419
|
scope: "mcp:read"
|
|
9159
9420
|
},
|
|
9160
9421
|
// carousel (already listed in content section above)
|
|
9422
|
+
// apps (MCP Apps — interactive UI inside the host)
|
|
9423
|
+
{
|
|
9424
|
+
name: "open_content_calendar",
|
|
9425
|
+
description: "Open an interactive drag-drop calendar of the user's scheduled posts inside the host (Claude Desktop / claude.ai). Renders an MCP App; backed by list_recent_posts, schedule_post, find_next_slots \u2014 no new tools needed.",
|
|
9426
|
+
module: "apps",
|
|
9427
|
+
scope: "mcp:read"
|
|
9428
|
+
},
|
|
9161
9429
|
// recipes
|
|
9162
9430
|
{
|
|
9163
9431
|
name: "list_recipes",
|
|
@@ -9182,6 +9450,19 @@ var TOOL_CATALOG = [
|
|
|
9182
9450
|
description: "Check the status and progress of a running recipe execution",
|
|
9183
9451
|
module: "recipes",
|
|
9184
9452
|
scope: "mcp:read"
|
|
9453
|
+
},
|
|
9454
|
+
// platform connection deep-link flow
|
|
9455
|
+
{
|
|
9456
|
+
name: "start_platform_connection",
|
|
9457
|
+
description: "Mint a single-use deep link for a user to complete platform OAuth in their browser",
|
|
9458
|
+
module: "distribution",
|
|
9459
|
+
scope: "mcp:distribute"
|
|
9460
|
+
},
|
|
9461
|
+
{
|
|
9462
|
+
name: "wait_for_connection",
|
|
9463
|
+
description: "Poll until a platform connection becomes active or timeout",
|
|
9464
|
+
module: "distribution",
|
|
9465
|
+
scope: "mcp:read"
|
|
9185
9466
|
}
|
|
9186
9467
|
];
|
|
9187
9468
|
function getToolsByModule(module) {
|
|
@@ -9381,7 +9662,7 @@ function registerPipelineTools(server) {
|
|
|
9381
9662
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9382
9663
|
} catch (err) {
|
|
9383
9664
|
const durationMs = Date.now() - startedAt;
|
|
9384
|
-
const message =
|
|
9665
|
+
const message = sanitizeError(err);
|
|
9385
9666
|
logMcpToolInvocation({
|
|
9386
9667
|
toolName: "check_pipeline_readiness",
|
|
9387
9668
|
status: "error",
|
|
@@ -9542,7 +9823,7 @@ function registerPipelineTools(server) {
|
|
|
9542
9823
|
} catch (deductErr) {
|
|
9543
9824
|
errors.push({
|
|
9544
9825
|
stage: "planning",
|
|
9545
|
-
message: `Credit deduction failed: ${
|
|
9826
|
+
message: `Credit deduction failed: ${sanitizeError(deductErr)}`
|
|
9546
9827
|
});
|
|
9547
9828
|
}
|
|
9548
9829
|
}
|
|
@@ -9713,7 +9994,7 @@ function registerPipelineTools(server) {
|
|
|
9713
9994
|
} catch (schedErr) {
|
|
9714
9995
|
errors.push({
|
|
9715
9996
|
stage: "schedule",
|
|
9716
|
-
message: `Failed to schedule ${post.id}: ${
|
|
9997
|
+
message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
|
|
9717
9998
|
});
|
|
9718
9999
|
}
|
|
9719
10000
|
}
|
|
@@ -9802,7 +10083,7 @@ function registerPipelineTools(server) {
|
|
|
9802
10083
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
9803
10084
|
} catch (err) {
|
|
9804
10085
|
const durationMs = Date.now() - startedAt;
|
|
9805
|
-
const message =
|
|
10086
|
+
const message = sanitizeError(err);
|
|
9806
10087
|
logMcpToolInvocation({
|
|
9807
10088
|
toolName: "run_content_pipeline",
|
|
9808
10089
|
status: "error",
|
|
@@ -10026,7 +10307,7 @@ function registerPipelineTools(server) {
|
|
|
10026
10307
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10027
10308
|
} catch (err) {
|
|
10028
10309
|
const durationMs = Date.now() - startedAt;
|
|
10029
|
-
const message =
|
|
10310
|
+
const message = sanitizeError(err);
|
|
10030
10311
|
logMcpToolInvocation({
|
|
10031
10312
|
toolName: "auto_approve_plan",
|
|
10032
10313
|
status: "error",
|
|
@@ -10204,7 +10485,7 @@ ${i + 1}. ${s.topic}`);
|
|
|
10204
10485
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10205
10486
|
} catch (err) {
|
|
10206
10487
|
const durationMs = Date.now() - startedAt;
|
|
10207
|
-
const message =
|
|
10488
|
+
const message = sanitizeError(err);
|
|
10208
10489
|
logMcpToolInvocation({
|
|
10209
10490
|
toolName: "suggest_next_content",
|
|
10210
10491
|
status: "error",
|
|
@@ -10543,7 +10824,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10543
10824
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10544
10825
|
} catch (err) {
|
|
10545
10826
|
const durationMs = Date.now() - startedAt;
|
|
10546
|
-
const message =
|
|
10827
|
+
const message = sanitizeError(err);
|
|
10547
10828
|
logMcpToolInvocation({
|
|
10548
10829
|
toolName: "generate_performance_digest",
|
|
10549
10830
|
status: "error",
|
|
@@ -10640,7 +10921,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
10640
10921
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10641
10922
|
} catch (err) {
|
|
10642
10923
|
const durationMs = Date.now() - startedAt;
|
|
10643
|
-
const message =
|
|
10924
|
+
const message = sanitizeError(err);
|
|
10644
10925
|
logMcpToolInvocation({
|
|
10645
10926
|
toolName: "detect_anomalies",
|
|
10646
10927
|
status: "error",
|
|
@@ -10945,11 +11226,11 @@ function hexToLab(hex) {
|
|
|
10945
11226
|
const lb = srgbToLinear(b);
|
|
10946
11227
|
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
10947
11228
|
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
10948
|
-
const
|
|
11229
|
+
const z31 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
10949
11230
|
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
10950
11231
|
const fx = f(x / 0.95047);
|
|
10951
11232
|
const fy = f(y / 1);
|
|
10952
|
-
const fz = f(
|
|
11233
|
+
const fz = f(z31 / 1.08883);
|
|
10953
11234
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
10954
11235
|
}
|
|
10955
11236
|
function deltaE2000(lab1, lab2) {
|
|
@@ -11669,7 +11950,7 @@ function registerCarouselTools(server) {
|
|
|
11669
11950
|
slideNumber: slide.slideNumber,
|
|
11670
11951
|
jobId: null,
|
|
11671
11952
|
model: image_model,
|
|
11672
|
-
error:
|
|
11953
|
+
error: sanitizeError(err)
|
|
11673
11954
|
};
|
|
11674
11955
|
}
|
|
11675
11956
|
})
|
|
@@ -11774,6 +12055,343 @@ function registerCarouselTools(server) {
|
|
|
11774
12055
|
);
|
|
11775
12056
|
}
|
|
11776
12057
|
|
|
12058
|
+
// src/apps/content-calendar.ts
|
|
12059
|
+
import {
|
|
12060
|
+
registerAppTool,
|
|
12061
|
+
registerAppResource,
|
|
12062
|
+
RESOURCE_MIME_TYPE
|
|
12063
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
12064
|
+
import { z as z28 } from "zod";
|
|
12065
|
+
import fs from "node:fs/promises";
|
|
12066
|
+
import path from "node:path";
|
|
12067
|
+
var CALENDAR_URI = "ui://content-calendar/mcp-app.html";
|
|
12068
|
+
function startOfCurrentWeekMonday() {
|
|
12069
|
+
const now = /* @__PURE__ */ new Date();
|
|
12070
|
+
const day = now.getDay();
|
|
12071
|
+
const monday = new Date(now);
|
|
12072
|
+
monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
|
|
12073
|
+
return monday.toISOString().split("T")[0];
|
|
12074
|
+
}
|
|
12075
|
+
function registerContentCalendarApp(server) {
|
|
12076
|
+
registerAppTool(
|
|
12077
|
+
server,
|
|
12078
|
+
"open_content_calendar",
|
|
12079
|
+
{
|
|
12080
|
+
title: "Content Calendar",
|
|
12081
|
+
description: "Open an interactive drag-drop calendar showing the user's scheduled posts for the current week. Users can reschedule via drag, filter by platform, drill into any post, or quick-create a new post. Backed by list_recent_posts, schedule_post, and find_next_slots \u2014 no new tools needed.",
|
|
12082
|
+
inputSchema: {
|
|
12083
|
+
start_date: z28.string().optional().describe("ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday.")
|
|
12084
|
+
},
|
|
12085
|
+
_meta: {
|
|
12086
|
+
ui: {
|
|
12087
|
+
resourceUri: CALENDAR_URI,
|
|
12088
|
+
csp: {
|
|
12089
|
+
"img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
|
|
12090
|
+
"connect-src": ["'self'"]
|
|
12091
|
+
}
|
|
12092
|
+
}
|
|
12093
|
+
}
|
|
12094
|
+
},
|
|
12095
|
+
async ({ start_date }, extra) => {
|
|
12096
|
+
const userScopes = extra.authInfo?.scopes ?? [];
|
|
12097
|
+
const fromDate = start_date ?? startOfCurrentWeekMonday();
|
|
12098
|
+
const { data: result, error } = await callEdgeFunction(
|
|
12099
|
+
"mcp-data",
|
|
12100
|
+
{
|
|
12101
|
+
action: "recent-posts",
|
|
12102
|
+
days: 14,
|
|
12103
|
+
limit: 50
|
|
12104
|
+
},
|
|
12105
|
+
{ timeoutMs: 15e3 }
|
|
12106
|
+
);
|
|
12107
|
+
if (error || !result?.success) {
|
|
12108
|
+
return {
|
|
12109
|
+
content: [
|
|
12110
|
+
{
|
|
12111
|
+
type: "text",
|
|
12112
|
+
text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
|
|
12113
|
+
}
|
|
12114
|
+
],
|
|
12115
|
+
isError: true
|
|
12116
|
+
};
|
|
12117
|
+
}
|
|
12118
|
+
const posts = (result.posts ?? []).filter((p) => {
|
|
12119
|
+
const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
|
|
12120
|
+
if (!ts) return false;
|
|
12121
|
+
return ts.split("T")[0] >= fromDate;
|
|
12122
|
+
});
|
|
12123
|
+
return {
|
|
12124
|
+
content: [
|
|
12125
|
+
{
|
|
12126
|
+
type: "text",
|
|
12127
|
+
text: JSON.stringify({ posts, scopes: userScopes })
|
|
12128
|
+
}
|
|
12129
|
+
]
|
|
12130
|
+
};
|
|
12131
|
+
}
|
|
12132
|
+
);
|
|
12133
|
+
registerAppResource(
|
|
12134
|
+
server,
|
|
12135
|
+
CALENDAR_URI,
|
|
12136
|
+
CALENDAR_URI,
|
|
12137
|
+
{ mimeType: RESOURCE_MIME_TYPE },
|
|
12138
|
+
async () => {
|
|
12139
|
+
const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
|
|
12140
|
+
try {
|
|
12141
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
12142
|
+
return {
|
|
12143
|
+
contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
|
|
12144
|
+
};
|
|
12145
|
+
} catch (err) {
|
|
12146
|
+
const errorHtml = `<!DOCTYPE html>
|
|
12147
|
+
<html><head><title>Content Calendar \u2014 unavailable</title></head>
|
|
12148
|
+
<body style="font-family:sans-serif;padding:24px;color:#444;">
|
|
12149
|
+
<h2>Content Calendar app bundle missing</h2>
|
|
12150
|
+
<p>The server registered <code>open_content_calendar</code> but
|
|
12151
|
+
<code>apps/content-calendar/dist/mcp-app.html</code> is not built.
|
|
12152
|
+
Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
|
|
12153
|
+
<p style="color:#999;font-size:12px;">${err.message}</p>
|
|
12154
|
+
</body></html>`;
|
|
12155
|
+
return {
|
|
12156
|
+
contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
|
|
12157
|
+
};
|
|
12158
|
+
}
|
|
12159
|
+
}
|
|
12160
|
+
);
|
|
12161
|
+
}
|
|
12162
|
+
|
|
12163
|
+
// src/tools/connections.ts
|
|
12164
|
+
import { z as z29 } from "zod";
|
|
12165
|
+
init_supabase();
|
|
12166
|
+
var PLATFORM_ENUM4 = [
|
|
12167
|
+
"youtube",
|
|
12168
|
+
"tiktok",
|
|
12169
|
+
"instagram",
|
|
12170
|
+
"twitter",
|
|
12171
|
+
"linkedin",
|
|
12172
|
+
"facebook",
|
|
12173
|
+
"threads",
|
|
12174
|
+
"bluesky",
|
|
12175
|
+
"shopify",
|
|
12176
|
+
"etsy"
|
|
12177
|
+
];
|
|
12178
|
+
function findActiveAccount(accounts, platform2) {
|
|
12179
|
+
const target = platform2.toLowerCase();
|
|
12180
|
+
return accounts.find((a) => a.platform.toLowerCase() === target && a.status === "active") ?? null;
|
|
12181
|
+
}
|
|
12182
|
+
function registerConnectionTools(server) {
|
|
12183
|
+
server.tool(
|
|
12184
|
+
"start_platform_connection",
|
|
12185
|
+
"Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
|
|
12186
|
+
{
|
|
12187
|
+
platform: z29.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
|
|
12188
|
+
response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
12189
|
+
},
|
|
12190
|
+
async ({ platform: platform2, response_format }) => {
|
|
12191
|
+
const format = response_format ?? "text";
|
|
12192
|
+
const startedAt = Date.now();
|
|
12193
|
+
const rl = checkRateLimit("read", `start_platform_connection:${platform2}`);
|
|
12194
|
+
if (!rl.allowed) {
|
|
12195
|
+
await logMcpToolInvocation({
|
|
12196
|
+
toolName: "start_platform_connection",
|
|
12197
|
+
status: "rate_limited",
|
|
12198
|
+
durationMs: Date.now() - startedAt,
|
|
12199
|
+
details: { retryAfter: rl.retryAfter, platform: platform2 }
|
|
12200
|
+
});
|
|
12201
|
+
return {
|
|
12202
|
+
content: [
|
|
12203
|
+
{
|
|
12204
|
+
type: "text",
|
|
12205
|
+
text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
|
|
12206
|
+
}
|
|
12207
|
+
],
|
|
12208
|
+
isError: true
|
|
12209
|
+
};
|
|
12210
|
+
}
|
|
12211
|
+
const { data, error } = await callEdgeFunction("mcp-data", { action: "mint-connection-nonce", platform: platform2 }, { timeoutMs: 1e4 });
|
|
12212
|
+
if (error || !data?.success || !data.deep_link) {
|
|
12213
|
+
const errMsg = error ?? data?.error ?? "Unknown error";
|
|
12214
|
+
await logMcpToolInvocation({
|
|
12215
|
+
toolName: "start_platform_connection",
|
|
12216
|
+
status: "error",
|
|
12217
|
+
durationMs: Date.now() - startedAt,
|
|
12218
|
+
details: { error: errMsg, platform: platform2 }
|
|
12219
|
+
});
|
|
12220
|
+
return {
|
|
12221
|
+
content: [
|
|
12222
|
+
{
|
|
12223
|
+
type: "text",
|
|
12224
|
+
text: `Failed to start ${platform2} connection: ${errMsg}`
|
|
12225
|
+
}
|
|
12226
|
+
],
|
|
12227
|
+
isError: true
|
|
12228
|
+
};
|
|
12229
|
+
}
|
|
12230
|
+
await logMcpToolInvocation({
|
|
12231
|
+
toolName: "start_platform_connection",
|
|
12232
|
+
status: "success",
|
|
12233
|
+
durationMs: Date.now() - startedAt,
|
|
12234
|
+
details: { platform: data.platform, expires_at: data.expires_at }
|
|
12235
|
+
});
|
|
12236
|
+
if (format === "json") {
|
|
12237
|
+
return {
|
|
12238
|
+
content: [
|
|
12239
|
+
{
|
|
12240
|
+
type: "text",
|
|
12241
|
+
text: JSON.stringify(
|
|
12242
|
+
{
|
|
12243
|
+
platform: data.platform,
|
|
12244
|
+
deep_link: data.deep_link,
|
|
12245
|
+
expires_at: data.expires_at,
|
|
12246
|
+
next_step: "Open deep_link in a browser, approve on the platform, then call wait_for_connection."
|
|
12247
|
+
},
|
|
12248
|
+
null,
|
|
12249
|
+
2
|
|
12250
|
+
)
|
|
12251
|
+
}
|
|
12252
|
+
],
|
|
12253
|
+
isError: false
|
|
12254
|
+
};
|
|
12255
|
+
}
|
|
12256
|
+
return {
|
|
12257
|
+
content: [
|
|
12258
|
+
{
|
|
12259
|
+
type: "text",
|
|
12260
|
+
text: [
|
|
12261
|
+
`${data.platform} connection ready.`,
|
|
12262
|
+
"",
|
|
12263
|
+
'Ask the user to open this link in a browser and click "Connect" on the platform:',
|
|
12264
|
+
` ${data.deep_link}`,
|
|
12265
|
+
"",
|
|
12266
|
+
`Link expires at: ${data.expires_at} (~2 minutes).`,
|
|
12267
|
+
"",
|
|
12268
|
+
"After they approve, call `wait_for_connection` with the same platform to confirm.",
|
|
12269
|
+
"This is a one-time browser setup \u2014 not another OAuth flow inside Claude."
|
|
12270
|
+
].join("\n")
|
|
12271
|
+
}
|
|
12272
|
+
],
|
|
12273
|
+
isError: false
|
|
12274
|
+
};
|
|
12275
|
+
}
|
|
12276
|
+
);
|
|
12277
|
+
server.tool(
|
|
12278
|
+
"wait_for_connection",
|
|
12279
|
+
"Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
|
|
12280
|
+
{
|
|
12281
|
+
platform: z29.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
|
|
12282
|
+
timeout_s: z29.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
|
|
12283
|
+
poll_interval_s: z29.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
|
|
12284
|
+
response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
12285
|
+
},
|
|
12286
|
+
async ({ platform: platform2, timeout_s, poll_interval_s, response_format }) => {
|
|
12287
|
+
const format = response_format ?? "text";
|
|
12288
|
+
const startedAt = Date.now();
|
|
12289
|
+
const timeoutMs = (timeout_s ?? 120) * 1e3;
|
|
12290
|
+
const intervalMs = (poll_interval_s ?? 5) * 1e3;
|
|
12291
|
+
const deadline = startedAt + timeoutMs;
|
|
12292
|
+
const rl = checkRateLimit("read", `wait_for_connection:${platform2}`);
|
|
12293
|
+
if (!rl.allowed) {
|
|
12294
|
+
return {
|
|
12295
|
+
content: [
|
|
12296
|
+
{
|
|
12297
|
+
type: "text",
|
|
12298
|
+
text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
|
|
12299
|
+
}
|
|
12300
|
+
],
|
|
12301
|
+
isError: true
|
|
12302
|
+
};
|
|
12303
|
+
}
|
|
12304
|
+
let attempts = 0;
|
|
12305
|
+
while (Date.now() < deadline) {
|
|
12306
|
+
attempts++;
|
|
12307
|
+
const { data, error } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
|
|
12308
|
+
if (!error && data?.success) {
|
|
12309
|
+
const found = findActiveAccount(data.accounts ?? [], platform2);
|
|
12310
|
+
if (found) {
|
|
12311
|
+
await logMcpToolInvocation({
|
|
12312
|
+
toolName: "wait_for_connection",
|
|
12313
|
+
status: "success",
|
|
12314
|
+
durationMs: Date.now() - startedAt,
|
|
12315
|
+
details: { platform: platform2, attempts, found: true }
|
|
12316
|
+
});
|
|
12317
|
+
if (format === "json") {
|
|
12318
|
+
return {
|
|
12319
|
+
content: [
|
|
12320
|
+
{
|
|
12321
|
+
type: "text",
|
|
12322
|
+
text: JSON.stringify(
|
|
12323
|
+
{
|
|
12324
|
+
connected: true,
|
|
12325
|
+
platform: found.platform,
|
|
12326
|
+
account_id: found.id,
|
|
12327
|
+
username: found.username,
|
|
12328
|
+
connected_at: found.created_at,
|
|
12329
|
+
attempts
|
|
12330
|
+
},
|
|
12331
|
+
null,
|
|
12332
|
+
2
|
|
12333
|
+
)
|
|
12334
|
+
}
|
|
12335
|
+
],
|
|
12336
|
+
isError: false
|
|
12337
|
+
};
|
|
12338
|
+
}
|
|
12339
|
+
return {
|
|
12340
|
+
content: [
|
|
12341
|
+
{
|
|
12342
|
+
type: "text",
|
|
12343
|
+
text: [
|
|
12344
|
+
`${found.platform} is connected.`,
|
|
12345
|
+
`Account: ${found.username || "(unnamed)"} (id=${found.id})`,
|
|
12346
|
+
`Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
|
|
12347
|
+
"Ready to call `schedule_post`."
|
|
12348
|
+
].join("\n")
|
|
12349
|
+
}
|
|
12350
|
+
],
|
|
12351
|
+
isError: false
|
|
12352
|
+
};
|
|
12353
|
+
}
|
|
12354
|
+
}
|
|
12355
|
+
const remaining = deadline - Date.now();
|
|
12356
|
+
if (remaining <= 0) break;
|
|
12357
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
|
|
12358
|
+
}
|
|
12359
|
+
await logMcpToolInvocation({
|
|
12360
|
+
toolName: "wait_for_connection",
|
|
12361
|
+
status: "error",
|
|
12362
|
+
durationMs: Date.now() - startedAt,
|
|
12363
|
+
details: { platform: platform2, attempts, found: false, reason: "timeout" }
|
|
12364
|
+
});
|
|
12365
|
+
const message = `${platform2} did not connect within ${timeout_s ?? 120}s (${attempts} polls). The user may not have completed the browser OAuth yet, or the link expired. Mint a new link with \`start_platform_connection\` and try again, or have the user go directly to socialneuron.com/settings/connections.`;
|
|
12366
|
+
if (format === "json") {
|
|
12367
|
+
return {
|
|
12368
|
+
content: [
|
|
12369
|
+
{
|
|
12370
|
+
type: "text",
|
|
12371
|
+
text: JSON.stringify(
|
|
12372
|
+
{
|
|
12373
|
+
connected: false,
|
|
12374
|
+
platform: platform2,
|
|
12375
|
+
attempts,
|
|
12376
|
+
timed_out: true,
|
|
12377
|
+
message
|
|
12378
|
+
},
|
|
12379
|
+
null,
|
|
12380
|
+
2
|
|
12381
|
+
)
|
|
12382
|
+
}
|
|
12383
|
+
],
|
|
12384
|
+
isError: true
|
|
12385
|
+
};
|
|
12386
|
+
}
|
|
12387
|
+
return {
|
|
12388
|
+
content: [{ type: "text", text: message }],
|
|
12389
|
+
isError: true
|
|
12390
|
+
};
|
|
12391
|
+
}
|
|
12392
|
+
);
|
|
12393
|
+
}
|
|
12394
|
+
|
|
11777
12395
|
// src/lib/register-tools.ts
|
|
11778
12396
|
function applyScopeEnforcement(server, scopeResolver) {
|
|
11779
12397
|
const originalTool = server.tool.bind(server);
|
|
@@ -11803,7 +12421,7 @@ function applyScopeEnforcement(server, scopeResolver) {
|
|
|
11803
12421
|
content: [
|
|
11804
12422
|
{
|
|
11805
12423
|
type: "text",
|
|
11806
|
-
text: `Permission denied: '${name}' requires scope '${requiredScope}'.
|
|
12424
|
+
text: `Permission denied: '${name}' requires scope '${requiredScope}'. Your scopes: [${userScopes.join(", ")}]. API-key users: regenerate your key with this scope at https://socialneuron.com/settings/developer. OAuth users (Claude Custom Connector): this scope is not enabled for your plan tier.`
|
|
11807
12425
|
}
|
|
11808
12426
|
],
|
|
11809
12427
|
isError: true
|
|
@@ -11879,21 +12497,25 @@ function registerAllTools(server, options) {
|
|
|
11879
12497
|
registerDigestTools(server);
|
|
11880
12498
|
registerBrandRuntimeTools(server);
|
|
11881
12499
|
registerCarouselTools(server);
|
|
12500
|
+
registerConnectionTools(server);
|
|
12501
|
+
if (!options?.skipApps) {
|
|
12502
|
+
registerContentCalendarApp(server);
|
|
12503
|
+
}
|
|
11882
12504
|
applyAnnotations(server);
|
|
11883
12505
|
}
|
|
11884
12506
|
|
|
11885
12507
|
// src/prompts.ts
|
|
11886
|
-
import { z as
|
|
12508
|
+
import { z as z30 } from "zod";
|
|
11887
12509
|
function registerPrompts(server) {
|
|
11888
12510
|
server.prompt(
|
|
11889
12511
|
"create_weekly_content_plan",
|
|
11890
12512
|
"Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
|
|
11891
12513
|
{
|
|
11892
|
-
niche:
|
|
11893
|
-
platforms:
|
|
12514
|
+
niche: z30.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
|
|
12515
|
+
platforms: z30.string().optional().describe(
|
|
11894
12516
|
'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
|
|
11895
12517
|
),
|
|
11896
|
-
tone:
|
|
12518
|
+
tone: z30.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
|
|
11897
12519
|
},
|
|
11898
12520
|
({ niche, platforms, tone }) => {
|
|
11899
12521
|
const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
|
|
@@ -11935,8 +12557,8 @@ After building the plan, use \`save_content_plan\` to save it.`
|
|
|
11935
12557
|
"analyze_top_content",
|
|
11936
12558
|
"Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
|
|
11937
12559
|
{
|
|
11938
|
-
timeframe:
|
|
11939
|
-
platform:
|
|
12560
|
+
timeframe: z30.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
|
|
12561
|
+
platform: z30.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
|
|
11940
12562
|
},
|
|
11941
12563
|
({ timeframe, platform: platform2 }) => {
|
|
11942
12564
|
const period = timeframe || "30 days";
|
|
@@ -11973,10 +12595,10 @@ Format as a clear, actionable performance report.`
|
|
|
11973
12595
|
"repurpose_content",
|
|
11974
12596
|
"Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
|
|
11975
12597
|
{
|
|
11976
|
-
source:
|
|
12598
|
+
source: z30.string().describe(
|
|
11977
12599
|
"The source content to repurpose \u2014 a URL, transcript, or the content text itself"
|
|
11978
12600
|
),
|
|
11979
|
-
target_platforms:
|
|
12601
|
+
target_platforms: z30.string().optional().describe(
|
|
11980
12602
|
'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
|
|
11981
12603
|
)
|
|
11982
12604
|
},
|
|
@@ -12018,9 +12640,9 @@ For each piece, include the platform, format, character count, and suggested pos
|
|
|
12018
12640
|
"setup_brand_voice",
|
|
12019
12641
|
"Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
|
|
12020
12642
|
{
|
|
12021
|
-
brand_name:
|
|
12022
|
-
industry:
|
|
12023
|
-
website:
|
|
12643
|
+
brand_name: z30.string().describe("Your brand or business name"),
|
|
12644
|
+
industry: z30.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
|
|
12645
|
+
website: z30.string().optional().describe("Your website URL for context")
|
|
12024
12646
|
},
|
|
12025
12647
|
({ brand_name, industry, website }) => {
|
|
12026
12648
|
const industryContext = industry ? ` in the ${industry} space` : "";
|
|
@@ -12479,7 +13101,7 @@ function isAllowedRedirectUri(uri) {
|
|
|
12479
13101
|
if ((parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && (parsed.pathname === "/oauth/callback" || parsed.pathname === "/oauth/callback/debug") && parsed.protocol === "http:") {
|
|
12480
13102
|
return true;
|
|
12481
13103
|
}
|
|
12482
|
-
if (parsed.protocol === "https:") {
|
|
13104
|
+
if (process.env.MCP_ALLOW_ANY_HTTPS_REDIRECT === "true" && parsed.protocol === "https:") {
|
|
12483
13105
|
return true;
|
|
12484
13106
|
}
|
|
12485
13107
|
} catch {
|
|
@@ -12598,7 +13220,7 @@ function createOAuthProvider(options) {
|
|
|
12598
13220
|
const controller = new AbortController();
|
|
12599
13221
|
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
12600
13222
|
try {
|
|
12601
|
-
await fetch(`${supabaseUrl}/functions/v1/mcp-auth?action=revoke-by-token`, {
|
|
13223
|
+
const response = await fetch(`${supabaseUrl}/functions/v1/mcp-auth?action=revoke-by-token`, {
|
|
12602
13224
|
method: "POST",
|
|
12603
13225
|
headers: {
|
|
12604
13226
|
Authorization: `Bearer ${supabaseAnonKey}`,
|
|
@@ -12607,9 +13229,17 @@ function createOAuthProvider(options) {
|
|
|
12607
13229
|
body: JSON.stringify({ token: request.token }),
|
|
12608
13230
|
signal: controller.signal
|
|
12609
13231
|
});
|
|
13232
|
+
if (!response.ok) {
|
|
13233
|
+
throw new Error(`Token revocation failed: HTTP ${response.status}`);
|
|
13234
|
+
}
|
|
13235
|
+
const data = await response.json().catch(() => ({ success: true }));
|
|
13236
|
+
if (data.success === false) {
|
|
13237
|
+
throw new Error(data.error ?? "Token revocation failed");
|
|
13238
|
+
}
|
|
12610
13239
|
} catch (err) {
|
|
12611
13240
|
const msg = err instanceof Error ? err.message : "unknown";
|
|
12612
13241
|
console.error(`[oauth] Token revocation call failed: ${msg}`);
|
|
13242
|
+
throw err;
|
|
12613
13243
|
} finally {
|
|
12614
13244
|
clearTimeout(timer);
|
|
12615
13245
|
}
|
|
@@ -12698,7 +13328,7 @@ var cleanupInterval = setInterval(
|
|
|
12698
13328
|
);
|
|
12699
13329
|
var app = express();
|
|
12700
13330
|
app.disable("x-powered-by");
|
|
12701
|
-
app.use(express.json());
|
|
13331
|
+
app.use(express.json({ limit: "1mb" }));
|
|
12702
13332
|
app.set("trust proxy", 1);
|
|
12703
13333
|
var ipBuckets = /* @__PURE__ */ new Map();
|
|
12704
13334
|
var IP_RATE_MAX = 60;
|
|
@@ -12779,9 +13409,13 @@ app.use((req, res, next) => {
|
|
|
12779
13409
|
next(err);
|
|
12780
13410
|
});
|
|
12781
13411
|
});
|
|
13412
|
+
function setNoStore(res) {
|
|
13413
|
+
res.setHeader("Cache-Control", "no-store");
|
|
13414
|
+
}
|
|
12782
13415
|
async function authenticateRequest(req, res, next) {
|
|
12783
13416
|
const authHeader = req.headers.authorization;
|
|
12784
13417
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
13418
|
+
setNoStore(res);
|
|
12785
13419
|
res.status(401).json({
|
|
12786
13420
|
error: "unauthorized",
|
|
12787
13421
|
error_description: "Bearer token required"
|
|
@@ -12806,10 +13440,12 @@ async function authenticateRequest(req, res, next) {
|
|
|
12806
13440
|
};
|
|
12807
13441
|
next();
|
|
12808
13442
|
} catch (err) {
|
|
12809
|
-
const message = err instanceof Error ? err
|
|
13443
|
+
const message = err instanceof Error ? sanitizeError(err) : "Token verification failed";
|
|
13444
|
+
console.error(`[MCP HTTP] Token verification failed: ${message}`);
|
|
13445
|
+
setNoStore(res);
|
|
12810
13446
|
res.status(401).json({
|
|
12811
13447
|
error: "invalid_token",
|
|
12812
|
-
error_description:
|
|
13448
|
+
error_description: "Token verification failed"
|
|
12813
13449
|
});
|
|
12814
13450
|
}
|
|
12815
13451
|
}
|
|
@@ -12823,124 +13459,13 @@ app.get("/.well-known/mcp/server-card.json", (_req, res) => {
|
|
|
12823
13459
|
required: true,
|
|
12824
13460
|
schemes: ["oauth2"]
|
|
12825
13461
|
},
|
|
12826
|
-
|
|
12827
|
-
|
|
12828
|
-
|
|
12829
|
-
|
|
12830
|
-
|
|
12831
|
-
|
|
12832
|
-
|
|
12833
|
-
required: ["prompt"]
|
|
12834
|
-
}
|
|
12835
|
-
},
|
|
12836
|
-
{
|
|
12837
|
-
name: "schedule_post",
|
|
12838
|
-
description: "Publish or schedule a post to connected social platforms.",
|
|
12839
|
-
inputSchema: {
|
|
12840
|
-
type: "object",
|
|
12841
|
-
properties: { content: { type: "string" }, platform: { type: "string" } },
|
|
12842
|
-
required: ["content", "platform"]
|
|
12843
|
-
}
|
|
12844
|
-
},
|
|
12845
|
-
{
|
|
12846
|
-
name: "fetch_analytics",
|
|
12847
|
-
description: "Get post performance metrics for connected platforms.",
|
|
12848
|
-
inputSchema: { type: "object", properties: { platform: { type: "string" } } }
|
|
12849
|
-
},
|
|
12850
|
-
{
|
|
12851
|
-
name: "extract_brand",
|
|
12852
|
-
description: "Analyze a website URL and extract brand identity data.",
|
|
12853
|
-
inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] }
|
|
12854
|
-
},
|
|
12855
|
-
{
|
|
12856
|
-
name: "plan_content_week",
|
|
12857
|
-
description: "Generate a full week content plan with platform-specific drafts.",
|
|
12858
|
-
inputSchema: {
|
|
12859
|
-
type: "object",
|
|
12860
|
-
properties: { niche: { type: "string" } },
|
|
12861
|
-
required: ["niche"]
|
|
12862
|
-
}
|
|
12863
|
-
},
|
|
12864
|
-
{
|
|
12865
|
-
name: "generate_video",
|
|
12866
|
-
description: "Start an async AI video generation job.",
|
|
12867
|
-
inputSchema: {
|
|
12868
|
-
type: "object",
|
|
12869
|
-
properties: { prompt: { type: "string" } },
|
|
12870
|
-
required: ["prompt"]
|
|
12871
|
-
}
|
|
12872
|
-
},
|
|
12873
|
-
{
|
|
12874
|
-
name: "generate_image",
|
|
12875
|
-
description: "Start an async AI image generation job.",
|
|
12876
|
-
inputSchema: {
|
|
12877
|
-
type: "object",
|
|
12878
|
-
properties: { prompt: { type: "string" } },
|
|
12879
|
-
required: ["prompt"]
|
|
12880
|
-
}
|
|
12881
|
-
},
|
|
12882
|
-
{
|
|
12883
|
-
name: "adapt_content",
|
|
12884
|
-
description: "Rewrite existing content for a different platform.",
|
|
12885
|
-
inputSchema: {
|
|
12886
|
-
type: "object",
|
|
12887
|
-
properties: { content: { type: "string" }, target_platform: { type: "string" } },
|
|
12888
|
-
required: ["content", "target_platform"]
|
|
12889
|
-
}
|
|
12890
|
-
},
|
|
12891
|
-
{
|
|
12892
|
-
name: "quality_check",
|
|
12893
|
-
description: "Score post quality across 7 categories (0-100).",
|
|
12894
|
-
inputSchema: {
|
|
12895
|
-
type: "object",
|
|
12896
|
-
properties: { content: { type: "string" } },
|
|
12897
|
-
required: ["content"]
|
|
12898
|
-
}
|
|
12899
|
-
},
|
|
12900
|
-
{
|
|
12901
|
-
name: "run_content_pipeline",
|
|
12902
|
-
description: "Full pipeline: trends \u2192 plan \u2192 quality check \u2192 schedule.",
|
|
12903
|
-
inputSchema: {
|
|
12904
|
-
type: "object",
|
|
12905
|
-
properties: { niche: { type: "string" } },
|
|
12906
|
-
required: ["niche"]
|
|
12907
|
-
}
|
|
12908
|
-
},
|
|
12909
|
-
{
|
|
12910
|
-
name: "fetch_trends",
|
|
12911
|
-
description: "Get current trending topics for content inspiration.",
|
|
12912
|
-
inputSchema: { type: "object", properties: { platform: { type: "string" } } }
|
|
12913
|
-
},
|
|
12914
|
-
{
|
|
12915
|
-
name: "get_credit_balance",
|
|
12916
|
-
description: "Check remaining credits and plan tier.",
|
|
12917
|
-
inputSchema: { type: "object", properties: {} }
|
|
12918
|
-
},
|
|
12919
|
-
{
|
|
12920
|
-
name: "list_connected_accounts",
|
|
12921
|
-
description: "Check which social platforms have active OAuth connections.",
|
|
12922
|
-
inputSchema: { type: "object", properties: {} }
|
|
12923
|
-
},
|
|
12924
|
-
{
|
|
12925
|
-
name: "get_brand_profile",
|
|
12926
|
-
description: "Load the active brand voice profile.",
|
|
12927
|
-
inputSchema: { type: "object", properties: {} }
|
|
12928
|
-
},
|
|
12929
|
-
{
|
|
12930
|
-
name: "list_comments",
|
|
12931
|
-
description: "List YouTube comments for moderation.",
|
|
12932
|
-
inputSchema: { type: "object", properties: {} }
|
|
12933
|
-
},
|
|
12934
|
-
{
|
|
12935
|
-
name: "reply_to_comment",
|
|
12936
|
-
description: "Reply to a YouTube comment.",
|
|
12937
|
-
inputSchema: {
|
|
12938
|
-
type: "object",
|
|
12939
|
-
properties: { comment_id: { type: "string" }, text: { type: "string" } },
|
|
12940
|
-
required: ["comment_id", "text"]
|
|
12941
|
-
}
|
|
12942
|
-
}
|
|
12943
|
-
],
|
|
13462
|
+
toolCount: TOOL_CATALOG.length,
|
|
13463
|
+
tools: TOOL_CATALOG.map((t) => ({
|
|
13464
|
+
name: t.name,
|
|
13465
|
+
description: t.description,
|
|
13466
|
+
module: t.module,
|
|
13467
|
+
scope: t.scope
|
|
13468
|
+
})),
|
|
12944
13469
|
prompts: [
|
|
12945
13470
|
{
|
|
12946
13471
|
name: "create_weekly_content_plan",
|
|
@@ -12998,6 +13523,7 @@ app.get("/health", (_req, res) => {
|
|
|
12998
13523
|
res.json({ status: "ok", version: MCP_VERSION });
|
|
12999
13524
|
});
|
|
13000
13525
|
app.get("/health/details", authenticateRequest, (_req, res) => {
|
|
13526
|
+
setNoStore(res);
|
|
13001
13527
|
res.json({
|
|
13002
13528
|
status: "ok",
|
|
13003
13529
|
version: MCP_VERSION,
|
|
@@ -13012,6 +13538,7 @@ app.get("/health/details", authenticateRequest, (_req, res) => {
|
|
|
13012
13538
|
app.post("/mcp", authenticateRequest, async (req, res) => {
|
|
13013
13539
|
const auth = req.auth;
|
|
13014
13540
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
13541
|
+
setNoStore(res);
|
|
13015
13542
|
const rl = checkRateLimit("read", auth.userId);
|
|
13016
13543
|
if (!rl.allowed) {
|
|
13017
13544
|
res.setHeader("Retry-After", String(rl.retryAfter));
|
|
@@ -13086,11 +13613,12 @@ app.post("/mcp", authenticateRequest, async (req, res) => {
|
|
|
13086
13613
|
const rawMessage = err instanceof Error ? err.message : "Internal server error";
|
|
13087
13614
|
console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
|
|
13088
13615
|
if (!res.headersSent) {
|
|
13089
|
-
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message:
|
|
13616
|
+
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError(err) } });
|
|
13090
13617
|
}
|
|
13091
13618
|
}
|
|
13092
13619
|
});
|
|
13093
13620
|
app.get("/mcp", authenticateRequest, async (req, res) => {
|
|
13621
|
+
setNoStore(res);
|
|
13094
13622
|
const sessionId = req.headers["mcp-session-id"];
|
|
13095
13623
|
if (!sessionId || !sessions.has(sessionId)) {
|
|
13096
13624
|
res.status(400).json({ error: "Invalid or missing session ID" });
|
|
@@ -13103,13 +13631,14 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
|
|
|
13103
13631
|
}
|
|
13104
13632
|
entry.lastActivity = Date.now();
|
|
13105
13633
|
res.setHeader("X-Accel-Buffering", "no");
|
|
13106
|
-
res.setHeader("Cache-Control", "no-
|
|
13634
|
+
res.setHeader("Cache-Control", "no-store");
|
|
13107
13635
|
await requestContext.run(
|
|
13108
13636
|
{ userId: req.auth.userId, scopes: req.auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
|
|
13109
13637
|
() => entry.transport.handleRequest(req, res)
|
|
13110
13638
|
);
|
|
13111
13639
|
});
|
|
13112
13640
|
app.delete("/mcp", authenticateRequest, async (req, res) => {
|
|
13641
|
+
setNoStore(res);
|
|
13113
13642
|
const sessionId = req.headers["mcp-session-id"];
|
|
13114
13643
|
if (!sessionId || !sessions.has(sessionId)) {
|
|
13115
13644
|
res.status(400).json({ error: "Invalid or missing session ID" });
|
|
@@ -13128,7 +13657,7 @@ app.delete("/mcp", authenticateRequest, async (req, res) => {
|
|
|
13128
13657
|
app.use((err, _req, res, _next) => {
|
|
13129
13658
|
console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
|
|
13130
13659
|
if (!res.headersSent) {
|
|
13131
|
-
res.status(500).json({ error: "internal_error", error_description:
|
|
13660
|
+
res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
|
|
13132
13661
|
}
|
|
13133
13662
|
});
|
|
13134
13663
|
var httpServer = app.listen(PORT, "0.0.0.0", () => {
|