@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/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var MCP_VERSION;
|
|
|
14
14
|
var init_version = __esm({
|
|
15
15
|
"src/lib/version.ts"() {
|
|
16
16
|
"use strict";
|
|
17
|
-
MCP_VERSION = "1.7.
|
|
17
|
+
MCP_VERSION = "1.7.9";
|
|
18
18
|
}
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -1273,6 +1273,13 @@ var init_tool_catalog = __esm({
|
|
|
1273
1273
|
scope: "mcp:read"
|
|
1274
1274
|
},
|
|
1275
1275
|
// carousel (already listed in content section above)
|
|
1276
|
+
// apps (MCP Apps — interactive UI inside the host)
|
|
1277
|
+
{
|
|
1278
|
+
name: "open_content_calendar",
|
|
1279
|
+
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.",
|
|
1280
|
+
module: "apps",
|
|
1281
|
+
scope: "mcp:read"
|
|
1282
|
+
},
|
|
1276
1283
|
// recipes
|
|
1277
1284
|
{
|
|
1278
1285
|
name: "list_recipes",
|
|
@@ -1297,6 +1304,19 @@ var init_tool_catalog = __esm({
|
|
|
1297
1304
|
description: "Check the status and progress of a running recipe execution",
|
|
1298
1305
|
module: "recipes",
|
|
1299
1306
|
scope: "mcp:read"
|
|
1307
|
+
},
|
|
1308
|
+
// platform connection deep-link flow
|
|
1309
|
+
{
|
|
1310
|
+
name: "start_platform_connection",
|
|
1311
|
+
description: "Mint a single-use deep link for a user to complete platform OAuth in their browser",
|
|
1312
|
+
module: "distribution",
|
|
1313
|
+
scope: "mcp:distribute"
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "wait_for_connection",
|
|
1317
|
+
description: "Poll until a platform connection becomes active or timeout",
|
|
1318
|
+
module: "distribution",
|
|
1319
|
+
scope: "mcp:read"
|
|
1300
1320
|
}
|
|
1301
1321
|
];
|
|
1302
1322
|
}
|
|
@@ -3309,9 +3329,9 @@ async function runSetup() {
|
|
|
3309
3329
|
console.error(` Key prefix: ${apiKey.substring(0, 12)}...`);
|
|
3310
3330
|
const configPaths = getConfigPaths();
|
|
3311
3331
|
let configured = false;
|
|
3312
|
-
for (const { path, name } of configPaths) {
|
|
3313
|
-
if (configureMcpClient(
|
|
3314
|
-
console.error(` Configured ${name}: ${
|
|
3332
|
+
for (const { path: path2, name } of configPaths) {
|
|
3333
|
+
if (configureMcpClient(path2)) {
|
|
3334
|
+
console.error(` Configured ${name}: ${path2}`);
|
|
3315
3335
|
configured = true;
|
|
3316
3336
|
}
|
|
3317
3337
|
}
|
|
@@ -3978,7 +3998,12 @@ var TOOL_SCOPES = {
|
|
|
3978
3998
|
suggest_next_content: "mcp:read",
|
|
3979
3999
|
// mcp:analytics (digest and anomalies are analytics-scoped)
|
|
3980
4000
|
generate_performance_digest: "mcp:analytics",
|
|
3981
|
-
detect_anomalies: "mcp:analytics"
|
|
4001
|
+
detect_anomalies: "mcp:analytics",
|
|
4002
|
+
// mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
|
|
4003
|
+
open_content_calendar: "mcp:read",
|
|
4004
|
+
// platform connection deep-link flow
|
|
4005
|
+
start_platform_connection: "mcp:distribute",
|
|
4006
|
+
wait_for_connection: "mcp:read"
|
|
3982
4007
|
};
|
|
3983
4008
|
function hasScope(userScopes, required) {
|
|
3984
4009
|
if (userScopes.includes(required)) return true;
|
|
@@ -5747,7 +5772,7 @@ var ERROR_PATTERNS = [
|
|
|
5747
5772
|
// Generic sensitive patterns (API keys, URLs with secrets)
|
|
5748
5773
|
[/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
|
|
5749
5774
|
];
|
|
5750
|
-
function
|
|
5775
|
+
function sanitizeError(error) {
|
|
5751
5776
|
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
|
5752
5777
|
if (process.env.NODE_ENV !== "production") {
|
|
5753
5778
|
console.error("[Error]", msg);
|
|
@@ -5760,6 +5785,171 @@ function sanitizeError2(error) {
|
|
|
5760
5785
|
return "An unexpected error occurred. Please try again.";
|
|
5761
5786
|
}
|
|
5762
5787
|
|
|
5788
|
+
// src/lib/ssrf.ts
|
|
5789
|
+
var BLOCKED_IP_PATTERNS = [
|
|
5790
|
+
// IPv4 localhost/loopback
|
|
5791
|
+
/^127\./,
|
|
5792
|
+
/^0\./,
|
|
5793
|
+
// IPv4 private ranges (RFC 1918)
|
|
5794
|
+
/^10\./,
|
|
5795
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
5796
|
+
/^192\.168\./,
|
|
5797
|
+
// IPv4 link-local
|
|
5798
|
+
/^169\.254\./,
|
|
5799
|
+
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
5800
|
+
/^169\.254\.169\.254$/,
|
|
5801
|
+
// IPv4 broadcast
|
|
5802
|
+
/^255\./,
|
|
5803
|
+
// Shared address space (RFC 6598)
|
|
5804
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
5805
|
+
];
|
|
5806
|
+
var BLOCKED_IPV6_PATTERNS = [
|
|
5807
|
+
/^::1$/i,
|
|
5808
|
+
// loopback
|
|
5809
|
+
/^::$/i,
|
|
5810
|
+
// unspecified
|
|
5811
|
+
/^fe[89ab][0-9a-f]:/i,
|
|
5812
|
+
// link-local fe80::/10
|
|
5813
|
+
/^fc[0-9a-f]:/i,
|
|
5814
|
+
// unique local fc00::/7
|
|
5815
|
+
/^fd[0-9a-f]:/i,
|
|
5816
|
+
// unique local fc00::/7
|
|
5817
|
+
/^::ffff:127\./i,
|
|
5818
|
+
// IPv4-mapped localhost
|
|
5819
|
+
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
5820
|
+
// IPv4-mapped private
|
|
5821
|
+
];
|
|
5822
|
+
var BLOCKED_HOSTNAMES = [
|
|
5823
|
+
"localhost",
|
|
5824
|
+
"localhost.localdomain",
|
|
5825
|
+
"local",
|
|
5826
|
+
"127.0.0.1",
|
|
5827
|
+
"0.0.0.0",
|
|
5828
|
+
"[::1]",
|
|
5829
|
+
"[::ffff:127.0.0.1]",
|
|
5830
|
+
// Cloud metadata endpoints
|
|
5831
|
+
"metadata.google.internal",
|
|
5832
|
+
"metadata.goog",
|
|
5833
|
+
"instance-data",
|
|
5834
|
+
"instance-data.ec2.internal"
|
|
5835
|
+
];
|
|
5836
|
+
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
5837
|
+
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
5838
|
+
function isBlockedIP(ip) {
|
|
5839
|
+
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
5840
|
+
if (normalized.includes(":")) {
|
|
5841
|
+
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
5842
|
+
}
|
|
5843
|
+
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
5844
|
+
}
|
|
5845
|
+
function isBlockedHostname(hostname) {
|
|
5846
|
+
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
5847
|
+
}
|
|
5848
|
+
function isIPAddress(hostname) {
|
|
5849
|
+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
5850
|
+
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
5851
|
+
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
5852
|
+
}
|
|
5853
|
+
async function validateUrlForSSRF(urlString) {
|
|
5854
|
+
try {
|
|
5855
|
+
const url = new URL(urlString);
|
|
5856
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
5857
|
+
return {
|
|
5858
|
+
isValid: false,
|
|
5859
|
+
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
5860
|
+
};
|
|
5861
|
+
}
|
|
5862
|
+
if (url.username || url.password) {
|
|
5863
|
+
return {
|
|
5864
|
+
isValid: false,
|
|
5865
|
+
error: "URLs with embedded credentials are not allowed."
|
|
5866
|
+
};
|
|
5867
|
+
}
|
|
5868
|
+
const hostname = url.hostname.toLowerCase();
|
|
5869
|
+
if (isBlockedHostname(hostname)) {
|
|
5870
|
+
return {
|
|
5871
|
+
isValid: false,
|
|
5872
|
+
error: "Access to internal/localhost addresses is not allowed."
|
|
5873
|
+
};
|
|
5874
|
+
}
|
|
5875
|
+
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
5876
|
+
return {
|
|
5877
|
+
isValid: false,
|
|
5878
|
+
error: "Access to private/internal IP addresses is not allowed."
|
|
5879
|
+
};
|
|
5880
|
+
}
|
|
5881
|
+
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
5882
|
+
if (BLOCKED_PORTS.includes(port)) {
|
|
5883
|
+
return {
|
|
5884
|
+
isValid: false,
|
|
5885
|
+
error: `Access to port ${port} is not allowed.`
|
|
5886
|
+
};
|
|
5887
|
+
}
|
|
5888
|
+
let resolvedIP;
|
|
5889
|
+
if (!isIPAddress(hostname)) {
|
|
5890
|
+
try {
|
|
5891
|
+
const dns = await import("node:dns");
|
|
5892
|
+
const resolver = new dns.promises.Resolver();
|
|
5893
|
+
const resolvedIPs = [];
|
|
5894
|
+
try {
|
|
5895
|
+
const aRecords = await resolver.resolve4(hostname);
|
|
5896
|
+
resolvedIPs.push(...aRecords);
|
|
5897
|
+
} catch {
|
|
5898
|
+
}
|
|
5899
|
+
try {
|
|
5900
|
+
const aaaaRecords = await resolver.resolve6(hostname);
|
|
5901
|
+
resolvedIPs.push(...aaaaRecords);
|
|
5902
|
+
} catch {
|
|
5903
|
+
}
|
|
5904
|
+
if (resolvedIPs.length === 0) {
|
|
5905
|
+
return {
|
|
5906
|
+
isValid: false,
|
|
5907
|
+
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
5908
|
+
};
|
|
5909
|
+
}
|
|
5910
|
+
for (const ip of resolvedIPs) {
|
|
5911
|
+
if (isBlockedIP(ip)) {
|
|
5912
|
+
return {
|
|
5913
|
+
isValid: false,
|
|
5914
|
+
error: "Hostname resolves to a private/internal IP address."
|
|
5915
|
+
};
|
|
5916
|
+
}
|
|
5917
|
+
}
|
|
5918
|
+
resolvedIP = resolvedIPs[0];
|
|
5919
|
+
} catch {
|
|
5920
|
+
return {
|
|
5921
|
+
isValid: false,
|
|
5922
|
+
error: "DNS resolution failed. Cannot verify hostname safety."
|
|
5923
|
+
};
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
|
|
5927
|
+
} catch (error) {
|
|
5928
|
+
return {
|
|
5929
|
+
isValid: false,
|
|
5930
|
+
error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
5931
|
+
};
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
function quickSSRFCheck(urlString) {
|
|
5935
|
+
try {
|
|
5936
|
+
const url = new URL(urlString);
|
|
5937
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
5938
|
+
return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
|
|
5939
|
+
}
|
|
5940
|
+
if (url.username || url.password) {
|
|
5941
|
+
return { isValid: false, error: "URLs with credentials not allowed" };
|
|
5942
|
+
}
|
|
5943
|
+
const hostname = url.hostname.toLowerCase();
|
|
5944
|
+
if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
5945
|
+
return { isValid: false, error: "Access to internal addresses not allowed" };
|
|
5946
|
+
}
|
|
5947
|
+
return { isValid: true, sanitizedUrl: url.toString() };
|
|
5948
|
+
} catch {
|
|
5949
|
+
return { isValid: false, error: "Invalid URL format" };
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
5952
|
+
|
|
5763
5953
|
// src/tools/distribution.ts
|
|
5764
5954
|
init_supabase();
|
|
5765
5955
|
init_quality();
|
|
@@ -5799,16 +5989,42 @@ function asEnvelope2(data) {
|
|
|
5799
5989
|
data
|
|
5800
5990
|
};
|
|
5801
5991
|
}
|
|
5992
|
+
function isAlreadyR2Signed(url) {
|
|
5993
|
+
try {
|
|
5994
|
+
const u = new URL(url);
|
|
5995
|
+
return u.searchParams.has("X-Amz-Signature");
|
|
5996
|
+
} catch {
|
|
5997
|
+
return false;
|
|
5998
|
+
}
|
|
5999
|
+
}
|
|
6000
|
+
async function rehostExternalUrl(mediaUrl, projectId) {
|
|
6001
|
+
if (isAlreadyR2Signed(mediaUrl)) {
|
|
6002
|
+
return { signedUrl: mediaUrl, r2Key: "" };
|
|
6003
|
+
}
|
|
6004
|
+
const ssrf = quickSSRFCheck(mediaUrl);
|
|
6005
|
+
if (!ssrf.isValid) {
|
|
6006
|
+
return { error: ssrf.error ?? "URL rejected by SSRF check" };
|
|
6007
|
+
}
|
|
6008
|
+
const { data, error } = await callEdgeFunction(
|
|
6009
|
+
"upload-to-r2",
|
|
6010
|
+
{ url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
|
|
6011
|
+
{ timeoutMs: 6e4 }
|
|
6012
|
+
);
|
|
6013
|
+
if (error || !data?.key || !data?.url) {
|
|
6014
|
+
return { error: error ?? "upload-to-r2 returned no key" };
|
|
6015
|
+
}
|
|
6016
|
+
return { signedUrl: data.url, r2Key: data.key };
|
|
6017
|
+
}
|
|
5802
6018
|
function registerDistributionTools(server2) {
|
|
5803
6019
|
server2.tool(
|
|
5804
6020
|
"schedule_post",
|
|
5805
|
-
'Publish or schedule a post to connected social platforms.
|
|
6021
|
+
'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.',
|
|
5806
6022
|
{
|
|
5807
6023
|
media_url: z3.string().optional().describe(
|
|
5808
|
-
"URL of the media file to post.
|
|
6024
|
+
"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."
|
|
5809
6025
|
),
|
|
5810
6026
|
media_urls: z3.array(z3.string()).optional().describe(
|
|
5811
|
-
"Array of 2-10 image URLs for carousel posts.
|
|
6027
|
+
"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."
|
|
5812
6028
|
),
|
|
5813
6029
|
r2_key: z3.string().optional().describe(
|
|
5814
6030
|
"R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
|
|
@@ -5897,7 +6113,10 @@ function registerDistributionTools(server2) {
|
|
|
5897
6113
|
),
|
|
5898
6114
|
project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
|
|
5899
6115
|
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
|
|
5900
|
-
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
|
|
6116
|
+
attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
|
|
6117
|
+
auto_rehost: z3.boolean().optional().describe(
|
|
6118
|
+
"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."
|
|
6119
|
+
)
|
|
5901
6120
|
},
|
|
5902
6121
|
async ({
|
|
5903
6122
|
media_url,
|
|
@@ -5911,6 +6130,7 @@ function registerDistributionTools(server2) {
|
|
|
5911
6130
|
project_id,
|
|
5912
6131
|
response_format,
|
|
5913
6132
|
attribution,
|
|
6133
|
+
auto_rehost,
|
|
5914
6134
|
r2_key,
|
|
5915
6135
|
r2_keys,
|
|
5916
6136
|
job_id,
|
|
@@ -6022,12 +6242,54 @@ function registerDistributionTools(server2) {
|
|
|
6022
6242
|
}
|
|
6023
6243
|
resolvedMediaUrls = resolved;
|
|
6024
6244
|
}
|
|
6245
|
+
const shouldRehost = auto_rehost !== false;
|
|
6246
|
+
if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
|
|
6247
|
+
const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
|
|
6248
|
+
if ("error" in rehost) {
|
|
6249
|
+
return {
|
|
6250
|
+
content: [
|
|
6251
|
+
{
|
|
6252
|
+
type: "text",
|
|
6253
|
+
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.`
|
|
6254
|
+
}
|
|
6255
|
+
],
|
|
6256
|
+
isError: true
|
|
6257
|
+
};
|
|
6258
|
+
}
|
|
6259
|
+
resolvedMediaUrl = rehost.signedUrl;
|
|
6260
|
+
}
|
|
6261
|
+
if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
|
|
6262
|
+
const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
|
|
6263
|
+
if (needsRehost.some(Boolean)) {
|
|
6264
|
+
const rehosted = await Promise.all(
|
|
6265
|
+
resolvedMediaUrls.map(
|
|
6266
|
+
(u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
|
|
6267
|
+
)
|
|
6268
|
+
);
|
|
6269
|
+
const failIdx = rehosted.findIndex((r) => "error" in r);
|
|
6270
|
+
if (failIdx !== -1) {
|
|
6271
|
+
const failed = rehosted[failIdx];
|
|
6272
|
+
return {
|
|
6273
|
+
content: [
|
|
6274
|
+
{
|
|
6275
|
+
type: "text",
|
|
6276
|
+
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.`
|
|
6277
|
+
}
|
|
6278
|
+
],
|
|
6279
|
+
isError: true
|
|
6280
|
+
};
|
|
6281
|
+
}
|
|
6282
|
+
resolvedMediaUrls = rehosted.map(
|
|
6283
|
+
(r) => r.signedUrl
|
|
6284
|
+
);
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6025
6287
|
} catch (resolveErr) {
|
|
6026
6288
|
return {
|
|
6027
6289
|
content: [
|
|
6028
6290
|
{
|
|
6029
6291
|
type: "text",
|
|
6030
|
-
text: `Failed to resolve media: ${
|
|
6292
|
+
text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
|
|
6031
6293
|
}
|
|
6032
6294
|
],
|
|
6033
6295
|
isError: true
|
|
@@ -6392,7 +6654,7 @@ Created with Social Neuron`;
|
|
|
6392
6654
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
|
|
6393
6655
|
} catch (err) {
|
|
6394
6656
|
const durationMs = Date.now() - startedAt;
|
|
6395
|
-
const message =
|
|
6657
|
+
const message = sanitizeError(err);
|
|
6396
6658
|
logMcpToolInvocation({
|
|
6397
6659
|
toolName: "find_next_slots",
|
|
6398
6660
|
status: "error",
|
|
@@ -6912,7 +7174,7 @@ Created with Social Neuron`;
|
|
|
6912
7174
|
};
|
|
6913
7175
|
} catch (err) {
|
|
6914
7176
|
const durationMs = Date.now() - startedAt;
|
|
6915
|
-
const message =
|
|
7177
|
+
const message = sanitizeError(err);
|
|
6916
7178
|
logMcpToolInvocation({
|
|
6917
7179
|
toolName: "schedule_content_plan",
|
|
6918
7180
|
status: "error",
|
|
@@ -6935,6 +7197,19 @@ import { readFile } from "node:fs/promises";
|
|
|
6935
7197
|
import { basename, extname } from "node:path";
|
|
6936
7198
|
init_supabase();
|
|
6937
7199
|
var MAX_BASE64_SIZE = 10 * 1024 * 1024;
|
|
7200
|
+
var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
|
|
7201
|
+
"image/png",
|
|
7202
|
+
"image/jpeg",
|
|
7203
|
+
"image/gif",
|
|
7204
|
+
"image/webp",
|
|
7205
|
+
"image/svg+xml",
|
|
7206
|
+
"video/mp4",
|
|
7207
|
+
"video/quicktime",
|
|
7208
|
+
"video/x-msvideo",
|
|
7209
|
+
"video/webm"
|
|
7210
|
+
]);
|
|
7211
|
+
var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
7212
|
+
var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
|
|
6938
7213
|
function maskR2Key(key) {
|
|
6939
7214
|
const segments = key.split("/");
|
|
6940
7215
|
return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
|
|
@@ -6955,21 +7230,35 @@ function inferContentType(filePath) {
|
|
|
6955
7230
|
};
|
|
6956
7231
|
return map[ext] || "application/octet-stream";
|
|
6957
7232
|
}
|
|
7233
|
+
function approxBase64Size(raw) {
|
|
7234
|
+
const len = raw.length;
|
|
7235
|
+
if (len === 0) return 0;
|
|
7236
|
+
let padding = 0;
|
|
7237
|
+
if (raw.endsWith("==")) padding = 2;
|
|
7238
|
+
else if (raw.endsWith("=")) padding = 1;
|
|
7239
|
+
return Math.floor(len * 3 / 4) - padding;
|
|
7240
|
+
}
|
|
6958
7241
|
function registerMediaTools(server2) {
|
|
6959
7242
|
server2.tool(
|
|
6960
7243
|
"upload_media",
|
|
6961
|
-
"Upload
|
|
7244
|
+
"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).",
|
|
6962
7245
|
{
|
|
6963
|
-
source: z4.string().describe(
|
|
6964
|
-
'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg").
|
|
7246
|
+
source: z4.string().optional().describe(
|
|
7247
|
+
'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.'
|
|
7248
|
+
),
|
|
7249
|
+
file_data: z4.string().optional().describe(
|
|
7250
|
+
'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.'
|
|
7251
|
+
),
|
|
7252
|
+
file_name: z4.string().optional().describe(
|
|
7253
|
+
'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
|
|
6965
7254
|
),
|
|
6966
7255
|
content_type: z4.string().optional().describe(
|
|
6967
|
-
'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension
|
|
7256
|
+
'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.'
|
|
6968
7257
|
),
|
|
6969
7258
|
project_id: z4.string().optional().describe("Project ID for R2 path organization."),
|
|
6970
7259
|
response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
6971
7260
|
},
|
|
6972
|
-
async ({ source, content_type, project_id, response_format }) => {
|
|
7261
|
+
async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
|
|
6973
7262
|
const format = response_format ?? "text";
|
|
6974
7263
|
const startedAt = Date.now();
|
|
6975
7264
|
const userId = await getDefaultUserId();
|
|
@@ -6991,46 +7280,187 @@ function registerMediaTools(server2) {
|
|
|
6991
7280
|
isError: true
|
|
6992
7281
|
};
|
|
6993
7282
|
}
|
|
6994
|
-
|
|
6995
|
-
|
|
7283
|
+
if (!source && !file_data) {
|
|
7284
|
+
return {
|
|
7285
|
+
content: [
|
|
7286
|
+
{
|
|
7287
|
+
type: "text",
|
|
7288
|
+
text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
|
|
7289
|
+
}
|
|
7290
|
+
],
|
|
7291
|
+
isError: true
|
|
7292
|
+
};
|
|
7293
|
+
}
|
|
7294
|
+
if (file_data) {
|
|
7295
|
+
let raw = file_data;
|
|
7296
|
+
let detectedType;
|
|
7297
|
+
const prefixMatch = raw.match(DATA_URI_PREFIX);
|
|
7298
|
+
if (prefixMatch) {
|
|
7299
|
+
detectedType = prefixMatch[1].trim().toLowerCase();
|
|
7300
|
+
raw = raw.slice(prefixMatch[0].length);
|
|
7301
|
+
}
|
|
7302
|
+
const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
|
|
7303
|
+
if (!ct) {
|
|
7304
|
+
return {
|
|
7305
|
+
content: [
|
|
7306
|
+
{
|
|
7307
|
+
type: "text",
|
|
7308
|
+
text: "content_type is required when file_data has no data: prefix."
|
|
7309
|
+
}
|
|
7310
|
+
],
|
|
7311
|
+
isError: true
|
|
7312
|
+
};
|
|
7313
|
+
}
|
|
7314
|
+
if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
|
|
7315
|
+
return {
|
|
7316
|
+
content: [
|
|
7317
|
+
{
|
|
7318
|
+
type: "text",
|
|
7319
|
+
text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
|
|
7320
|
+
}
|
|
7321
|
+
],
|
|
7322
|
+
isError: true
|
|
7323
|
+
};
|
|
7324
|
+
}
|
|
7325
|
+
const stripped = raw.replace(/\s+/g, "");
|
|
7326
|
+
if (!BASE64_CHARS.test(stripped)) {
|
|
7327
|
+
return {
|
|
7328
|
+
content: [
|
|
7329
|
+
{
|
|
7330
|
+
type: "text",
|
|
7331
|
+
text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
|
|
7332
|
+
}
|
|
7333
|
+
],
|
|
7334
|
+
isError: true
|
|
7335
|
+
};
|
|
7336
|
+
}
|
|
7337
|
+
const approxSize = approxBase64Size(stripped);
|
|
7338
|
+
if (approxSize > MAX_BASE64_SIZE) {
|
|
7339
|
+
return {
|
|
7340
|
+
content: [
|
|
7341
|
+
{
|
|
7342
|
+
type: "text",
|
|
7343
|
+
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.`
|
|
7344
|
+
}
|
|
7345
|
+
],
|
|
7346
|
+
isError: true
|
|
7347
|
+
};
|
|
7348
|
+
}
|
|
7349
|
+
const safeName = basename(file_name ?? "upload");
|
|
7350
|
+
const uploadBody2 = {
|
|
7351
|
+
fileData: `data:${ct};base64,${stripped}`,
|
|
7352
|
+
contentType: ct,
|
|
7353
|
+
fileName: safeName,
|
|
7354
|
+
projectId: project_id
|
|
7355
|
+
};
|
|
7356
|
+
const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
|
|
7357
|
+
if (error2) {
|
|
7358
|
+
await logMcpToolInvocation({
|
|
7359
|
+
toolName: "upload_media",
|
|
7360
|
+
status: "error",
|
|
7361
|
+
durationMs: Date.now() - startedAt,
|
|
7362
|
+
details: { error: error2, source: "base64", contentType: ct, size: approxSize }
|
|
7363
|
+
});
|
|
7364
|
+
return {
|
|
7365
|
+
content: [{ type: "text", text: `Upload failed: ${error2}` }],
|
|
7366
|
+
isError: true
|
|
7367
|
+
};
|
|
7368
|
+
}
|
|
7369
|
+
if (!data2?.key) {
|
|
7370
|
+
return {
|
|
7371
|
+
content: [{ type: "text", text: "Upload returned no R2 key." }],
|
|
7372
|
+
isError: true
|
|
7373
|
+
};
|
|
7374
|
+
}
|
|
7375
|
+
await logMcpToolInvocation({
|
|
7376
|
+
toolName: "upload_media",
|
|
7377
|
+
status: "success",
|
|
7378
|
+
durationMs: Date.now() - startedAt,
|
|
7379
|
+
details: {
|
|
7380
|
+
source: "base64",
|
|
7381
|
+
r2Key: data2.key,
|
|
7382
|
+
size: data2.size,
|
|
7383
|
+
contentType: data2.contentType
|
|
7384
|
+
}
|
|
7385
|
+
});
|
|
7386
|
+
if (format === "json") {
|
|
7387
|
+
return {
|
|
7388
|
+
content: [
|
|
7389
|
+
{
|
|
7390
|
+
type: "text",
|
|
7391
|
+
text: JSON.stringify(
|
|
7392
|
+
{
|
|
7393
|
+
r2_key: data2.key,
|
|
7394
|
+
signed_url: data2.url,
|
|
7395
|
+
size: data2.size,
|
|
7396
|
+
content_type: data2.contentType
|
|
7397
|
+
},
|
|
7398
|
+
null,
|
|
7399
|
+
2
|
|
7400
|
+
)
|
|
7401
|
+
}
|
|
7402
|
+
],
|
|
7403
|
+
isError: false
|
|
7404
|
+
};
|
|
7405
|
+
}
|
|
7406
|
+
return {
|
|
7407
|
+
content: [
|
|
7408
|
+
{
|
|
7409
|
+
type: "text",
|
|
7410
|
+
text: [
|
|
7411
|
+
"Media uploaded successfully.",
|
|
7412
|
+
`Media key: ${maskR2Key(data2.key)}`,
|
|
7413
|
+
`Signed URL: ${data2.url}`,
|
|
7414
|
+
`Size: ${(data2.size / 1024).toFixed(0)}KB`,
|
|
7415
|
+
`Type: ${data2.contentType}`,
|
|
7416
|
+
"",
|
|
7417
|
+
"Use job_id or response_format=json with schedule_post to post to any platform."
|
|
7418
|
+
].join("\n")
|
|
7419
|
+
}
|
|
7420
|
+
],
|
|
7421
|
+
isError: false
|
|
7422
|
+
};
|
|
7423
|
+
}
|
|
7424
|
+
const src = source;
|
|
7425
|
+
const isUrl = src.startsWith("http://") || src.startsWith("https://");
|
|
6996
7426
|
let uploadBody;
|
|
6997
7427
|
if (isUrl) {
|
|
6998
|
-
const ct = content_type || inferContentType(
|
|
7428
|
+
const ct = content_type || inferContentType(src);
|
|
6999
7429
|
uploadBody = {
|
|
7000
|
-
url:
|
|
7430
|
+
url: src,
|
|
7001
7431
|
contentType: ct,
|
|
7002
|
-
fileName: basename(new URL(
|
|
7432
|
+
fileName: basename(file_name ?? new URL(src).pathname) || "upload",
|
|
7003
7433
|
projectId: project_id
|
|
7004
7434
|
};
|
|
7005
7435
|
} else {
|
|
7006
7436
|
let fileBuffer;
|
|
7007
7437
|
try {
|
|
7008
|
-
fileBuffer = await readFile(
|
|
7009
|
-
} catch
|
|
7438
|
+
fileBuffer = await readFile(src);
|
|
7439
|
+
} catch {
|
|
7010
7440
|
await logMcpToolInvocation({
|
|
7011
7441
|
toolName: "upload_media",
|
|
7012
7442
|
status: "error",
|
|
7013
7443
|
durationMs: Date.now() - startedAt,
|
|
7014
|
-
details: { error:
|
|
7444
|
+
details: { error: "File not found", source: "local" }
|
|
7015
7445
|
});
|
|
7016
7446
|
return {
|
|
7017
7447
|
content: [
|
|
7018
7448
|
{
|
|
7019
7449
|
type: "text",
|
|
7020
|
-
text: `File not found or not readable: ${
|
|
7450
|
+
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.`
|
|
7021
7451
|
}
|
|
7022
7452
|
],
|
|
7023
7453
|
isError: true
|
|
7024
7454
|
};
|
|
7025
7455
|
}
|
|
7026
|
-
const ct = content_type || inferContentType(
|
|
7456
|
+
const ct = content_type || inferContentType(src);
|
|
7027
7457
|
if (fileBuffer.length > MAX_BASE64_SIZE) {
|
|
7028
7458
|
const { data: putData, error: putError } = await callEdgeFunction(
|
|
7029
7459
|
"get-signed-url",
|
|
7030
7460
|
{
|
|
7031
7461
|
operation: "put",
|
|
7032
7462
|
contentType: ct,
|
|
7033
|
-
filename: basename(
|
|
7463
|
+
filename: basename(file_name ?? src),
|
|
7034
7464
|
projectId: project_id
|
|
7035
7465
|
},
|
|
7036
7466
|
{ timeoutMs: 1e4 }
|
|
@@ -7132,7 +7562,7 @@ function registerMediaTools(server2) {
|
|
|
7132
7562
|
uploadBody = {
|
|
7133
7563
|
fileData: base64,
|
|
7134
7564
|
contentType: ct,
|
|
7135
|
-
fileName: basename(
|
|
7565
|
+
fileName: basename(file_name ?? src),
|
|
7136
7566
|
projectId: project_id
|
|
7137
7567
|
};
|
|
7138
7568
|
}
|
|
@@ -7449,230 +7879,81 @@ function registerAnalyticsTools(server2) {
|
|
|
7449
7879
|
`Analytics refresh triggered successfully.`,
|
|
7450
7880
|
` Posts processed: ${result.postsProcessed}`,
|
|
7451
7881
|
` Jobs queued: ${queued}`
|
|
7452
|
-
];
|
|
7453
|
-
if (errored > 0) {
|
|
7454
|
-
lines.push(` Errors: ${errored}`);
|
|
7455
|
-
}
|
|
7456
|
-
await logMcpToolInvocation({
|
|
7457
|
-
toolName: "refresh_platform_analytics",
|
|
7458
|
-
status: "success",
|
|
7459
|
-
durationMs: Date.now() - startedAt,
|
|
7460
|
-
details: {
|
|
7461
|
-
postsProcessed: result.postsProcessed,
|
|
7462
|
-
queued,
|
|
7463
|
-
errored
|
|
7464
|
-
}
|
|
7465
|
-
});
|
|
7466
|
-
if (format === "json") {
|
|
7467
|
-
return {
|
|
7468
|
-
content: [
|
|
7469
|
-
{
|
|
7470
|
-
type: "text",
|
|
7471
|
-
text: JSON.stringify(
|
|
7472
|
-
asEnvelope3({
|
|
7473
|
-
success: true,
|
|
7474
|
-
postsProcessed: result.postsProcessed,
|
|
7475
|
-
queued,
|
|
7476
|
-
errored
|
|
7477
|
-
}),
|
|
7478
|
-
null,
|
|
7479
|
-
2
|
|
7480
|
-
)
|
|
7481
|
-
}
|
|
7482
|
-
]
|
|
7483
|
-
};
|
|
7484
|
-
}
|
|
7485
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
7486
|
-
}
|
|
7487
|
-
);
|
|
7488
|
-
}
|
|
7489
|
-
function formatAnalytics(summary, days, format) {
|
|
7490
|
-
if (format === "json") {
|
|
7491
|
-
return {
|
|
7492
|
-
content: [
|
|
7493
|
-
{ type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
|
|
7494
|
-
]
|
|
7495
|
-
};
|
|
7496
|
-
}
|
|
7497
|
-
const lines = [
|
|
7498
|
-
`Analytics Summary (last ${days} days${summary.platform ? `, ${summary.platform}` : ""}):`,
|
|
7499
|
-
"",
|
|
7500
|
-
` Total Views: ${summary.totalViews.toLocaleString()}`,
|
|
7501
|
-
` Total Engagement: ${summary.totalEngagement.toLocaleString()}`,
|
|
7502
|
-
` Posts Analyzed: ${summary.postCount}`,
|
|
7503
|
-
""
|
|
7504
|
-
];
|
|
7505
|
-
if (summary.posts.length > 0) {
|
|
7506
|
-
lines.push("Top Posts:");
|
|
7507
|
-
const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
|
|
7508
|
-
for (const post of sorted.slice(0, 10)) {
|
|
7509
|
-
const title = post.title || "(untitled)";
|
|
7510
|
-
let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
|
|
7511
|
-
if (post.content_type || post.model_used) {
|
|
7512
|
-
const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
|
|
7513
|
-
line += ` (${meta})`;
|
|
7514
|
-
}
|
|
7515
|
-
lines.push(line);
|
|
7516
|
-
}
|
|
7517
|
-
}
|
|
7518
|
-
return {
|
|
7519
|
-
content: [{ type: "text", text: lines.join("\n") }]
|
|
7520
|
-
};
|
|
7521
|
-
}
|
|
7522
|
-
|
|
7523
|
-
// src/tools/brand.ts
|
|
7524
|
-
init_edge_function();
|
|
7525
|
-
init_supabase();
|
|
7526
|
-
import { z as z6 } from "zod";
|
|
7527
|
-
|
|
7528
|
-
// src/lib/ssrf.ts
|
|
7529
|
-
var BLOCKED_IP_PATTERNS = [
|
|
7530
|
-
// IPv4 localhost/loopback
|
|
7531
|
-
/^127\./,
|
|
7532
|
-
/^0\./,
|
|
7533
|
-
// IPv4 private ranges (RFC 1918)
|
|
7534
|
-
/^10\./,
|
|
7535
|
-
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
7536
|
-
/^192\.168\./,
|
|
7537
|
-
// IPv4 link-local
|
|
7538
|
-
/^169\.254\./,
|
|
7539
|
-
// Cloud metadata endpoint (AWS, GCP, Azure)
|
|
7540
|
-
/^169\.254\.169\.254$/,
|
|
7541
|
-
// IPv4 broadcast
|
|
7542
|
-
/^255\./,
|
|
7543
|
-
// Shared address space (RFC 6598)
|
|
7544
|
-
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
|
|
7545
|
-
];
|
|
7546
|
-
var BLOCKED_IPV6_PATTERNS = [
|
|
7547
|
-
/^::1$/i,
|
|
7548
|
-
// loopback
|
|
7549
|
-
/^::$/i,
|
|
7550
|
-
// unspecified
|
|
7551
|
-
/^fe[89ab][0-9a-f]:/i,
|
|
7552
|
-
// link-local fe80::/10
|
|
7553
|
-
/^fc[0-9a-f]:/i,
|
|
7554
|
-
// unique local fc00::/7
|
|
7555
|
-
/^fd[0-9a-f]:/i,
|
|
7556
|
-
// unique local fc00::/7
|
|
7557
|
-
/^::ffff:127\./i,
|
|
7558
|
-
// IPv4-mapped localhost
|
|
7559
|
-
/^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
|
|
7560
|
-
// IPv4-mapped private
|
|
7561
|
-
];
|
|
7562
|
-
var BLOCKED_HOSTNAMES = [
|
|
7563
|
-
"localhost",
|
|
7564
|
-
"localhost.localdomain",
|
|
7565
|
-
"local",
|
|
7566
|
-
"127.0.0.1",
|
|
7567
|
-
"0.0.0.0",
|
|
7568
|
-
"[::1]",
|
|
7569
|
-
"[::ffff:127.0.0.1]",
|
|
7570
|
-
// Cloud metadata endpoints
|
|
7571
|
-
"metadata.google.internal",
|
|
7572
|
-
"metadata.goog",
|
|
7573
|
-
"instance-data",
|
|
7574
|
-
"instance-data.ec2.internal"
|
|
7575
|
-
];
|
|
7576
|
-
var ALLOWED_PROTOCOLS = ["http:", "https:"];
|
|
7577
|
-
var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
|
|
7578
|
-
function isBlockedIP(ip) {
|
|
7579
|
-
const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
|
|
7580
|
-
if (normalized.includes(":")) {
|
|
7581
|
-
return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
7582
|
-
}
|
|
7583
|
-
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
7584
|
-
}
|
|
7585
|
-
function isBlockedHostname(hostname) {
|
|
7586
|
-
return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
7587
|
-
}
|
|
7588
|
-
function isIPAddress(hostname) {
|
|
7589
|
-
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
7590
|
-
const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
|
|
7591
|
-
return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
|
|
7592
|
-
}
|
|
7593
|
-
async function validateUrlForSSRF(urlString) {
|
|
7594
|
-
try {
|
|
7595
|
-
const url = new URL(urlString);
|
|
7596
|
-
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
7597
|
-
return {
|
|
7598
|
-
isValid: false,
|
|
7599
|
-
error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
|
|
7600
|
-
};
|
|
7601
|
-
}
|
|
7602
|
-
if (url.username || url.password) {
|
|
7603
|
-
return {
|
|
7604
|
-
isValid: false,
|
|
7605
|
-
error: "URLs with embedded credentials are not allowed."
|
|
7606
|
-
};
|
|
7607
|
-
}
|
|
7608
|
-
const hostname = url.hostname.toLowerCase();
|
|
7609
|
-
if (isBlockedHostname(hostname)) {
|
|
7610
|
-
return {
|
|
7611
|
-
isValid: false,
|
|
7612
|
-
error: "Access to internal/localhost addresses is not allowed."
|
|
7613
|
-
};
|
|
7614
|
-
}
|
|
7615
|
-
if (isIPAddress(hostname) && isBlockedIP(hostname)) {
|
|
7616
|
-
return {
|
|
7617
|
-
isValid: false,
|
|
7618
|
-
error: "Access to private/internal IP addresses is not allowed."
|
|
7619
|
-
};
|
|
7620
|
-
}
|
|
7621
|
-
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
|
|
7622
|
-
if (BLOCKED_PORTS.includes(port)) {
|
|
7623
|
-
return {
|
|
7624
|
-
isValid: false,
|
|
7625
|
-
error: `Access to port ${port} is not allowed.`
|
|
7626
|
-
};
|
|
7627
|
-
}
|
|
7628
|
-
let resolvedIP;
|
|
7629
|
-
if (!isIPAddress(hostname)) {
|
|
7630
|
-
try {
|
|
7631
|
-
const dns = await import("node:dns");
|
|
7632
|
-
const resolver = new dns.promises.Resolver();
|
|
7633
|
-
const resolvedIPs = [];
|
|
7634
|
-
try {
|
|
7635
|
-
const aRecords = await resolver.resolve4(hostname);
|
|
7636
|
-
resolvedIPs.push(...aRecords);
|
|
7637
|
-
} catch {
|
|
7638
|
-
}
|
|
7639
|
-
try {
|
|
7640
|
-
const aaaaRecords = await resolver.resolve6(hostname);
|
|
7641
|
-
resolvedIPs.push(...aaaaRecords);
|
|
7642
|
-
} catch {
|
|
7643
|
-
}
|
|
7644
|
-
if (resolvedIPs.length === 0) {
|
|
7645
|
-
return {
|
|
7646
|
-
isValid: false,
|
|
7647
|
-
error: "DNS resolution failed: hostname did not resolve to any address."
|
|
7648
|
-
};
|
|
7649
|
-
}
|
|
7650
|
-
for (const ip of resolvedIPs) {
|
|
7651
|
-
if (isBlockedIP(ip)) {
|
|
7652
|
-
return {
|
|
7653
|
-
isValid: false,
|
|
7654
|
-
error: "Hostname resolves to a private/internal IP address."
|
|
7655
|
-
};
|
|
7656
|
-
}
|
|
7882
|
+
];
|
|
7883
|
+
if (errored > 0) {
|
|
7884
|
+
lines.push(` Errors: ${errored}`);
|
|
7885
|
+
}
|
|
7886
|
+
await logMcpToolInvocation({
|
|
7887
|
+
toolName: "refresh_platform_analytics",
|
|
7888
|
+
status: "success",
|
|
7889
|
+
durationMs: Date.now() - startedAt,
|
|
7890
|
+
details: {
|
|
7891
|
+
postsProcessed: result.postsProcessed,
|
|
7892
|
+
queued,
|
|
7893
|
+
errored
|
|
7657
7894
|
}
|
|
7658
|
-
|
|
7659
|
-
|
|
7895
|
+
});
|
|
7896
|
+
if (format === "json") {
|
|
7660
7897
|
return {
|
|
7661
|
-
|
|
7662
|
-
|
|
7898
|
+
content: [
|
|
7899
|
+
{
|
|
7900
|
+
type: "text",
|
|
7901
|
+
text: JSON.stringify(
|
|
7902
|
+
asEnvelope3({
|
|
7903
|
+
success: true,
|
|
7904
|
+
postsProcessed: result.postsProcessed,
|
|
7905
|
+
queued,
|
|
7906
|
+
errored
|
|
7907
|
+
}),
|
|
7908
|
+
null,
|
|
7909
|
+
2
|
|
7910
|
+
)
|
|
7911
|
+
}
|
|
7912
|
+
]
|
|
7663
7913
|
};
|
|
7664
7914
|
}
|
|
7915
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
7665
7916
|
}
|
|
7666
|
-
|
|
7667
|
-
|
|
7917
|
+
);
|
|
7918
|
+
}
|
|
7919
|
+
function formatAnalytics(summary, days, format) {
|
|
7920
|
+
if (format === "json") {
|
|
7668
7921
|
return {
|
|
7669
|
-
|
|
7670
|
-
|
|
7922
|
+
content: [
|
|
7923
|
+
{ type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
|
|
7924
|
+
]
|
|
7671
7925
|
};
|
|
7672
7926
|
}
|
|
7927
|
+
const lines = [
|
|
7928
|
+
`Analytics Summary (last ${days} days${summary.platform ? `, ${summary.platform}` : ""}):`,
|
|
7929
|
+
"",
|
|
7930
|
+
` Total Views: ${summary.totalViews.toLocaleString()}`,
|
|
7931
|
+
` Total Engagement: ${summary.totalEngagement.toLocaleString()}`,
|
|
7932
|
+
` Posts Analyzed: ${summary.postCount}`,
|
|
7933
|
+
""
|
|
7934
|
+
];
|
|
7935
|
+
if (summary.posts.length > 0) {
|
|
7936
|
+
lines.push("Top Posts:");
|
|
7937
|
+
const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
|
|
7938
|
+
for (const post of sorted.slice(0, 10)) {
|
|
7939
|
+
const title = post.title || "(untitled)";
|
|
7940
|
+
let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
|
|
7941
|
+
if (post.content_type || post.model_used) {
|
|
7942
|
+
const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
|
|
7943
|
+
line += ` (${meta})`;
|
|
7944
|
+
}
|
|
7945
|
+
lines.push(line);
|
|
7946
|
+
}
|
|
7947
|
+
}
|
|
7948
|
+
return {
|
|
7949
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
7950
|
+
};
|
|
7673
7951
|
}
|
|
7674
7952
|
|
|
7675
7953
|
// src/tools/brand.ts
|
|
7954
|
+
init_edge_function();
|
|
7955
|
+
init_supabase();
|
|
7956
|
+
import { z as z6 } from "zod";
|
|
7676
7957
|
init_version();
|
|
7677
7958
|
function asEnvelope4(data) {
|
|
7678
7959
|
return {
|
|
@@ -7822,7 +8103,7 @@ function registerBrandTools(server2) {
|
|
|
7822
8103
|
);
|
|
7823
8104
|
server2.tool(
|
|
7824
8105
|
"save_brand_profile",
|
|
7825
|
-
"
|
|
8106
|
+
"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.",
|
|
7826
8107
|
{
|
|
7827
8108
|
project_id: z6.string().uuid().optional().describe("Project ID. Defaults to active project context."),
|
|
7828
8109
|
brand_context: z6.record(z6.string(), z6.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
|
|
@@ -8196,7 +8477,7 @@ function registerScreenshotTools(server2) {
|
|
|
8196
8477
|
};
|
|
8197
8478
|
} catch (err) {
|
|
8198
8479
|
await closeBrowser();
|
|
8199
|
-
const message =
|
|
8480
|
+
const message = sanitizeError(err);
|
|
8200
8481
|
await logMcpToolInvocation({
|
|
8201
8482
|
toolName: "capture_app_page",
|
|
8202
8483
|
status: "error",
|
|
@@ -8352,7 +8633,7 @@ function registerScreenshotTools(server2) {
|
|
|
8352
8633
|
};
|
|
8353
8634
|
} catch (err) {
|
|
8354
8635
|
await closeBrowser();
|
|
8355
|
-
const message =
|
|
8636
|
+
const message = sanitizeError(err);
|
|
8356
8637
|
await logMcpToolInvocation({
|
|
8357
8638
|
toolName: "capture_screenshot",
|
|
8358
8639
|
status: "error",
|
|
@@ -8646,7 +8927,7 @@ function registerRemotionTools(server2) {
|
|
|
8646
8927
|
]
|
|
8647
8928
|
};
|
|
8648
8929
|
} catch (err) {
|
|
8649
|
-
const message =
|
|
8930
|
+
const message = sanitizeError(err);
|
|
8650
8931
|
await logMcpToolInvocation({
|
|
8651
8932
|
toolName: "render_demo_video",
|
|
8652
8933
|
status: "error",
|
|
@@ -8774,7 +9055,7 @@ function registerRemotionTools(server2) {
|
|
|
8774
9055
|
]
|
|
8775
9056
|
};
|
|
8776
9057
|
} catch (err) {
|
|
8777
|
-
const message =
|
|
9058
|
+
const message = sanitizeError(err);
|
|
8778
9059
|
await logMcpToolInvocation({
|
|
8779
9060
|
toolName: "render_template_video",
|
|
8780
9061
|
status: "error",
|
|
@@ -9407,7 +9688,7 @@ function registerCommentsTools(server2) {
|
|
|
9407
9688
|
);
|
|
9408
9689
|
server2.tool(
|
|
9409
9690
|
"post_comment",
|
|
9410
|
-
"Post a new top-level comment on a YouTube video.",
|
|
9691
|
+
"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.",
|
|
9411
9692
|
{
|
|
9412
9693
|
video_id: z11.string().describe("The YouTube video ID to comment on."),
|
|
9413
9694
|
text: z11.string().min(1).describe("The comment text."),
|
|
@@ -9478,7 +9759,7 @@ function registerCommentsTools(server2) {
|
|
|
9478
9759
|
);
|
|
9479
9760
|
server2.tool(
|
|
9480
9761
|
"moderate_comment",
|
|
9481
|
-
|
|
9762
|
+
'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.',
|
|
9482
9763
|
{
|
|
9483
9764
|
comment_id: z11.string().describe("The comment ID to moderate."),
|
|
9484
9765
|
moderation_status: z11.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
|
|
@@ -10711,7 +10992,7 @@ function registerExtractionTools(server2) {
|
|
|
10711
10992
|
};
|
|
10712
10993
|
} catch (err) {
|
|
10713
10994
|
const durationMs = Date.now() - startedAt;
|
|
10714
|
-
const message =
|
|
10995
|
+
const message = sanitizeError(err);
|
|
10715
10996
|
logMcpToolInvocation({
|
|
10716
10997
|
toolName: "extract_url_content",
|
|
10717
10998
|
status: "error",
|
|
@@ -11271,7 +11552,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
11271
11552
|
}
|
|
11272
11553
|
} catch (persistErr) {
|
|
11273
11554
|
const durationMs2 = Date.now() - startedAt;
|
|
11274
|
-
const message =
|
|
11555
|
+
const message = sanitizeError(persistErr);
|
|
11275
11556
|
logMcpToolInvocation({
|
|
11276
11557
|
toolName: "plan_content_week",
|
|
11277
11558
|
status: "error",
|
|
@@ -11303,7 +11584,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
11303
11584
|
};
|
|
11304
11585
|
} catch (err) {
|
|
11305
11586
|
const durationMs = Date.now() - startedAt;
|
|
11306
|
-
const message =
|
|
11587
|
+
const message = sanitizeError(err);
|
|
11307
11588
|
logMcpToolInvocation({
|
|
11308
11589
|
toolName: "plan_content_week",
|
|
11309
11590
|
status: "error",
|
|
@@ -11395,7 +11676,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
11395
11676
|
};
|
|
11396
11677
|
} catch (err) {
|
|
11397
11678
|
const durationMs = Date.now() - startedAt;
|
|
11398
|
-
const message =
|
|
11679
|
+
const message = sanitizeError(err);
|
|
11399
11680
|
logMcpToolInvocation({
|
|
11400
11681
|
toolName: "save_content_plan",
|
|
11401
11682
|
status: "error",
|
|
@@ -11411,7 +11692,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
11411
11692
|
);
|
|
11412
11693
|
server2.tool(
|
|
11413
11694
|
"get_content_plan",
|
|
11414
|
-
"
|
|
11695
|
+
"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.",
|
|
11415
11696
|
{
|
|
11416
11697
|
plan_id: z20.string().uuid().describe("Persisted content plan ID"),
|
|
11417
11698
|
response_format: z20.enum(["text", "json"]).default("json")
|
|
@@ -11465,7 +11746,7 @@ ${rawText.slice(0, 1e3)}`
|
|
|
11465
11746
|
);
|
|
11466
11747
|
server2.tool(
|
|
11467
11748
|
"update_content_plan",
|
|
11468
|
-
"
|
|
11749
|
+
"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.",
|
|
11469
11750
|
{
|
|
11470
11751
|
plan_id: z20.string().uuid(),
|
|
11471
11752
|
post_updates: z20.array(
|
|
@@ -11608,7 +11889,7 @@ function asEnvelope17(data) {
|
|
|
11608
11889
|
function registerPlanApprovalTools(server2) {
|
|
11609
11890
|
server2.tool(
|
|
11610
11891
|
"create_plan_approvals",
|
|
11611
|
-
|
|
11892
|
+
'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.',
|
|
11612
11893
|
{
|
|
11613
11894
|
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
11614
11895
|
posts: z21.array(
|
|
@@ -11688,7 +11969,7 @@ function registerPlanApprovalTools(server2) {
|
|
|
11688
11969
|
);
|
|
11689
11970
|
server2.tool(
|
|
11690
11971
|
"list_plan_approvals",
|
|
11691
|
-
"List
|
|
11972
|
+
"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.",
|
|
11692
11973
|
{
|
|
11693
11974
|
plan_id: z21.string().uuid().describe("Content plan ID"),
|
|
11694
11975
|
status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
|
|
@@ -11748,7 +12029,7 @@ function registerPlanApprovalTools(server2) {
|
|
|
11748
12029
|
);
|
|
11749
12030
|
server2.tool(
|
|
11750
12031
|
"respond_plan_approval",
|
|
11751
|
-
|
|
12032
|
+
'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.',
|
|
11752
12033
|
{
|
|
11753
12034
|
approval_id: z21.string().uuid().describe("Approval item ID"),
|
|
11754
12035
|
decision: z21.enum(["approved", "rejected", "edited"]),
|
|
@@ -12007,7 +12288,7 @@ function registerPipelineTools(server2) {
|
|
|
12007
12288
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
12008
12289
|
} catch (err) {
|
|
12009
12290
|
const durationMs = Date.now() - startedAt;
|
|
12010
|
-
const message =
|
|
12291
|
+
const message = sanitizeError(err);
|
|
12011
12292
|
logMcpToolInvocation({
|
|
12012
12293
|
toolName: "check_pipeline_readiness",
|
|
12013
12294
|
status: "error",
|
|
@@ -12168,7 +12449,7 @@ function registerPipelineTools(server2) {
|
|
|
12168
12449
|
} catch (deductErr) {
|
|
12169
12450
|
errors.push({
|
|
12170
12451
|
stage: "planning",
|
|
12171
|
-
message: `Credit deduction failed: ${
|
|
12452
|
+
message: `Credit deduction failed: ${sanitizeError(deductErr)}`
|
|
12172
12453
|
});
|
|
12173
12454
|
}
|
|
12174
12455
|
}
|
|
@@ -12339,7 +12620,7 @@ function registerPipelineTools(server2) {
|
|
|
12339
12620
|
} catch (schedErr) {
|
|
12340
12621
|
errors.push({
|
|
12341
12622
|
stage: "schedule",
|
|
12342
|
-
message: `Failed to schedule ${post.id}: ${
|
|
12623
|
+
message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
|
|
12343
12624
|
});
|
|
12344
12625
|
}
|
|
12345
12626
|
}
|
|
@@ -12428,7 +12709,7 @@ function registerPipelineTools(server2) {
|
|
|
12428
12709
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
12429
12710
|
} catch (err) {
|
|
12430
12711
|
const durationMs = Date.now() - startedAt;
|
|
12431
|
-
const message =
|
|
12712
|
+
const message = sanitizeError(err);
|
|
12432
12713
|
logMcpToolInvocation({
|
|
12433
12714
|
toolName: "run_content_pipeline",
|
|
12434
12715
|
status: "error",
|
|
@@ -12652,7 +12933,7 @@ function registerPipelineTools(server2) {
|
|
|
12652
12933
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
12653
12934
|
} catch (err) {
|
|
12654
12935
|
const durationMs = Date.now() - startedAt;
|
|
12655
|
-
const message =
|
|
12936
|
+
const message = sanitizeError(err);
|
|
12656
12937
|
logMcpToolInvocation({
|
|
12657
12938
|
toolName: "auto_approve_plan",
|
|
12658
12939
|
status: "error",
|
|
@@ -12832,7 +13113,7 @@ ${i + 1}. ${s.topic}`);
|
|
|
12832
13113
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
12833
13114
|
} catch (err) {
|
|
12834
13115
|
const durationMs = Date.now() - startedAt;
|
|
12835
|
-
const message =
|
|
13116
|
+
const message = sanitizeError(err);
|
|
12836
13117
|
logMcpToolInvocation({
|
|
12837
13118
|
toolName: "suggest_next_content",
|
|
12838
13119
|
status: "error",
|
|
@@ -13173,7 +13454,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
13173
13454
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
13174
13455
|
} catch (err) {
|
|
13175
13456
|
const durationMs = Date.now() - startedAt;
|
|
13176
|
-
const message =
|
|
13457
|
+
const message = sanitizeError(err);
|
|
13177
13458
|
logMcpToolInvocation({
|
|
13178
13459
|
toolName: "generate_performance_digest",
|
|
13179
13460
|
status: "error",
|
|
@@ -13270,7 +13551,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
|
|
|
13270
13551
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
13271
13552
|
} catch (err) {
|
|
13272
13553
|
const durationMs = Date.now() - startedAt;
|
|
13273
|
-
const message =
|
|
13554
|
+
const message = sanitizeError(err);
|
|
13274
13555
|
logMcpToolInvocation({
|
|
13275
13556
|
toolName: "detect_anomalies",
|
|
13276
13557
|
status: "error",
|
|
@@ -13577,11 +13858,11 @@ function hexToLab(hex) {
|
|
|
13577
13858
|
const lb = srgbToLinear(b);
|
|
13578
13859
|
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
13579
13860
|
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
13580
|
-
const
|
|
13861
|
+
const z31 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
13581
13862
|
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
13582
13863
|
const fx = f(x / 0.95047);
|
|
13583
13864
|
const fy = f(y / 1);
|
|
13584
|
-
const fz = f(
|
|
13865
|
+
const fz = f(z31 / 1.08883);
|
|
13585
13866
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
13586
13867
|
}
|
|
13587
13868
|
function deltaE2000(lab1, lab2) {
|
|
@@ -14303,7 +14584,7 @@ function registerCarouselTools(server2) {
|
|
|
14303
14584
|
slideNumber: slide.slideNumber,
|
|
14304
14585
|
jobId: null,
|
|
14305
14586
|
model: image_model,
|
|
14306
|
-
error:
|
|
14587
|
+
error: sanitizeError(err)
|
|
14307
14588
|
};
|
|
14308
14589
|
}
|
|
14309
14590
|
})
|
|
@@ -14408,6 +14689,345 @@ function registerCarouselTools(server2) {
|
|
|
14408
14689
|
);
|
|
14409
14690
|
}
|
|
14410
14691
|
|
|
14692
|
+
// src/apps/content-calendar.ts
|
|
14693
|
+
init_edge_function();
|
|
14694
|
+
import {
|
|
14695
|
+
registerAppTool,
|
|
14696
|
+
registerAppResource,
|
|
14697
|
+
RESOURCE_MIME_TYPE
|
|
14698
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
14699
|
+
import { z as z28 } from "zod";
|
|
14700
|
+
import fs from "node:fs/promises";
|
|
14701
|
+
import path from "node:path";
|
|
14702
|
+
var CALENDAR_URI = "ui://content-calendar/mcp-app.html";
|
|
14703
|
+
function startOfCurrentWeekMonday() {
|
|
14704
|
+
const now = /* @__PURE__ */ new Date();
|
|
14705
|
+
const day = now.getDay();
|
|
14706
|
+
const monday = new Date(now);
|
|
14707
|
+
monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
|
|
14708
|
+
return monday.toISOString().split("T")[0];
|
|
14709
|
+
}
|
|
14710
|
+
function registerContentCalendarApp(server2) {
|
|
14711
|
+
registerAppTool(
|
|
14712
|
+
server2,
|
|
14713
|
+
"open_content_calendar",
|
|
14714
|
+
{
|
|
14715
|
+
title: "Content Calendar",
|
|
14716
|
+
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.",
|
|
14717
|
+
inputSchema: {
|
|
14718
|
+
start_date: z28.string().optional().describe("ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday.")
|
|
14719
|
+
},
|
|
14720
|
+
_meta: {
|
|
14721
|
+
ui: {
|
|
14722
|
+
resourceUri: CALENDAR_URI,
|
|
14723
|
+
csp: {
|
|
14724
|
+
"img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
|
|
14725
|
+
"connect-src": ["'self'"]
|
|
14726
|
+
}
|
|
14727
|
+
}
|
|
14728
|
+
}
|
|
14729
|
+
},
|
|
14730
|
+
async ({ start_date }, extra) => {
|
|
14731
|
+
const userScopes = extra.authInfo?.scopes ?? [];
|
|
14732
|
+
const fromDate = start_date ?? startOfCurrentWeekMonday();
|
|
14733
|
+
const { data: result, error } = await callEdgeFunction(
|
|
14734
|
+
"mcp-data",
|
|
14735
|
+
{
|
|
14736
|
+
action: "recent-posts",
|
|
14737
|
+
days: 14,
|
|
14738
|
+
limit: 50
|
|
14739
|
+
},
|
|
14740
|
+
{ timeoutMs: 15e3 }
|
|
14741
|
+
);
|
|
14742
|
+
if (error || !result?.success) {
|
|
14743
|
+
return {
|
|
14744
|
+
content: [
|
|
14745
|
+
{
|
|
14746
|
+
type: "text",
|
|
14747
|
+
text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
|
|
14748
|
+
}
|
|
14749
|
+
],
|
|
14750
|
+
isError: true
|
|
14751
|
+
};
|
|
14752
|
+
}
|
|
14753
|
+
const posts = (result.posts ?? []).filter((p) => {
|
|
14754
|
+
const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
|
|
14755
|
+
if (!ts) return false;
|
|
14756
|
+
return ts.split("T")[0] >= fromDate;
|
|
14757
|
+
});
|
|
14758
|
+
return {
|
|
14759
|
+
content: [
|
|
14760
|
+
{
|
|
14761
|
+
type: "text",
|
|
14762
|
+
text: JSON.stringify({ posts, scopes: userScopes })
|
|
14763
|
+
}
|
|
14764
|
+
]
|
|
14765
|
+
};
|
|
14766
|
+
}
|
|
14767
|
+
);
|
|
14768
|
+
registerAppResource(
|
|
14769
|
+
server2,
|
|
14770
|
+
CALENDAR_URI,
|
|
14771
|
+
CALENDAR_URI,
|
|
14772
|
+
{ mimeType: RESOURCE_MIME_TYPE },
|
|
14773
|
+
async () => {
|
|
14774
|
+
const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
|
|
14775
|
+
try {
|
|
14776
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
14777
|
+
return {
|
|
14778
|
+
contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
|
|
14779
|
+
};
|
|
14780
|
+
} catch (err) {
|
|
14781
|
+
const errorHtml = `<!DOCTYPE html>
|
|
14782
|
+
<html><head><title>Content Calendar \u2014 unavailable</title></head>
|
|
14783
|
+
<body style="font-family:sans-serif;padding:24px;color:#444;">
|
|
14784
|
+
<h2>Content Calendar app bundle missing</h2>
|
|
14785
|
+
<p>The server registered <code>open_content_calendar</code> but
|
|
14786
|
+
<code>apps/content-calendar/dist/mcp-app.html</code> is not built.
|
|
14787
|
+
Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
|
|
14788
|
+
<p style="color:#999;font-size:12px;">${err.message}</p>
|
|
14789
|
+
</body></html>`;
|
|
14790
|
+
return {
|
|
14791
|
+
contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
|
|
14792
|
+
};
|
|
14793
|
+
}
|
|
14794
|
+
}
|
|
14795
|
+
);
|
|
14796
|
+
}
|
|
14797
|
+
|
|
14798
|
+
// src/tools/connections.ts
|
|
14799
|
+
init_edge_function();
|
|
14800
|
+
import { z as z29 } from "zod";
|
|
14801
|
+
init_supabase();
|
|
14802
|
+
var PLATFORM_ENUM4 = [
|
|
14803
|
+
"youtube",
|
|
14804
|
+
"tiktok",
|
|
14805
|
+
"instagram",
|
|
14806
|
+
"twitter",
|
|
14807
|
+
"linkedin",
|
|
14808
|
+
"facebook",
|
|
14809
|
+
"threads",
|
|
14810
|
+
"bluesky",
|
|
14811
|
+
"shopify",
|
|
14812
|
+
"etsy"
|
|
14813
|
+
];
|
|
14814
|
+
function findActiveAccount(accounts, platform3) {
|
|
14815
|
+
const target = platform3.toLowerCase();
|
|
14816
|
+
return accounts.find((a) => a.platform.toLowerCase() === target && a.status === "active") ?? null;
|
|
14817
|
+
}
|
|
14818
|
+
function registerConnectionTools(server2) {
|
|
14819
|
+
server2.tool(
|
|
14820
|
+
"start_platform_connection",
|
|
14821
|
+
"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.",
|
|
14822
|
+
{
|
|
14823
|
+
platform: z29.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
|
|
14824
|
+
response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
14825
|
+
},
|
|
14826
|
+
async ({ platform: platform3, response_format }) => {
|
|
14827
|
+
const format = response_format ?? "text";
|
|
14828
|
+
const startedAt = Date.now();
|
|
14829
|
+
const rl = checkRateLimit("read", `start_platform_connection:${platform3}`);
|
|
14830
|
+
if (!rl.allowed) {
|
|
14831
|
+
await logMcpToolInvocation({
|
|
14832
|
+
toolName: "start_platform_connection",
|
|
14833
|
+
status: "rate_limited",
|
|
14834
|
+
durationMs: Date.now() - startedAt,
|
|
14835
|
+
details: { retryAfter: rl.retryAfter, platform: platform3 }
|
|
14836
|
+
});
|
|
14837
|
+
return {
|
|
14838
|
+
content: [
|
|
14839
|
+
{
|
|
14840
|
+
type: "text",
|
|
14841
|
+
text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
|
|
14842
|
+
}
|
|
14843
|
+
],
|
|
14844
|
+
isError: true
|
|
14845
|
+
};
|
|
14846
|
+
}
|
|
14847
|
+
const { data, error } = await callEdgeFunction("mcp-data", { action: "mint-connection-nonce", platform: platform3 }, { timeoutMs: 1e4 });
|
|
14848
|
+
if (error || !data?.success || !data.deep_link) {
|
|
14849
|
+
const errMsg = error ?? data?.error ?? "Unknown error";
|
|
14850
|
+
await logMcpToolInvocation({
|
|
14851
|
+
toolName: "start_platform_connection",
|
|
14852
|
+
status: "error",
|
|
14853
|
+
durationMs: Date.now() - startedAt,
|
|
14854
|
+
details: { error: errMsg, platform: platform3 }
|
|
14855
|
+
});
|
|
14856
|
+
return {
|
|
14857
|
+
content: [
|
|
14858
|
+
{
|
|
14859
|
+
type: "text",
|
|
14860
|
+
text: `Failed to start ${platform3} connection: ${errMsg}`
|
|
14861
|
+
}
|
|
14862
|
+
],
|
|
14863
|
+
isError: true
|
|
14864
|
+
};
|
|
14865
|
+
}
|
|
14866
|
+
await logMcpToolInvocation({
|
|
14867
|
+
toolName: "start_platform_connection",
|
|
14868
|
+
status: "success",
|
|
14869
|
+
durationMs: Date.now() - startedAt,
|
|
14870
|
+
details: { platform: data.platform, expires_at: data.expires_at }
|
|
14871
|
+
});
|
|
14872
|
+
if (format === "json") {
|
|
14873
|
+
return {
|
|
14874
|
+
content: [
|
|
14875
|
+
{
|
|
14876
|
+
type: "text",
|
|
14877
|
+
text: JSON.stringify(
|
|
14878
|
+
{
|
|
14879
|
+
platform: data.platform,
|
|
14880
|
+
deep_link: data.deep_link,
|
|
14881
|
+
expires_at: data.expires_at,
|
|
14882
|
+
next_step: "Open deep_link in a browser, approve on the platform, then call wait_for_connection."
|
|
14883
|
+
},
|
|
14884
|
+
null,
|
|
14885
|
+
2
|
|
14886
|
+
)
|
|
14887
|
+
}
|
|
14888
|
+
],
|
|
14889
|
+
isError: false
|
|
14890
|
+
};
|
|
14891
|
+
}
|
|
14892
|
+
return {
|
|
14893
|
+
content: [
|
|
14894
|
+
{
|
|
14895
|
+
type: "text",
|
|
14896
|
+
text: [
|
|
14897
|
+
`${data.platform} connection ready.`,
|
|
14898
|
+
"",
|
|
14899
|
+
'Ask the user to open this link in a browser and click "Connect" on the platform:',
|
|
14900
|
+
` ${data.deep_link}`,
|
|
14901
|
+
"",
|
|
14902
|
+
`Link expires at: ${data.expires_at} (~2 minutes).`,
|
|
14903
|
+
"",
|
|
14904
|
+
"After they approve, call `wait_for_connection` with the same platform to confirm.",
|
|
14905
|
+
"This is a one-time browser setup \u2014 not another OAuth flow inside Claude."
|
|
14906
|
+
].join("\n")
|
|
14907
|
+
}
|
|
14908
|
+
],
|
|
14909
|
+
isError: false
|
|
14910
|
+
};
|
|
14911
|
+
}
|
|
14912
|
+
);
|
|
14913
|
+
server2.tool(
|
|
14914
|
+
"wait_for_connection",
|
|
14915
|
+
"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.",
|
|
14916
|
+
{
|
|
14917
|
+
platform: z29.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
|
|
14918
|
+
timeout_s: z29.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
|
|
14919
|
+
poll_interval_s: z29.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
|
|
14920
|
+
response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
14921
|
+
},
|
|
14922
|
+
async ({ platform: platform3, timeout_s, poll_interval_s, response_format }) => {
|
|
14923
|
+
const format = response_format ?? "text";
|
|
14924
|
+
const startedAt = Date.now();
|
|
14925
|
+
const timeoutMs = (timeout_s ?? 120) * 1e3;
|
|
14926
|
+
const intervalMs = (poll_interval_s ?? 5) * 1e3;
|
|
14927
|
+
const deadline = startedAt + timeoutMs;
|
|
14928
|
+
const rl = checkRateLimit("read", `wait_for_connection:${platform3}`);
|
|
14929
|
+
if (!rl.allowed) {
|
|
14930
|
+
return {
|
|
14931
|
+
content: [
|
|
14932
|
+
{
|
|
14933
|
+
type: "text",
|
|
14934
|
+
text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
|
|
14935
|
+
}
|
|
14936
|
+
],
|
|
14937
|
+
isError: true
|
|
14938
|
+
};
|
|
14939
|
+
}
|
|
14940
|
+
let attempts = 0;
|
|
14941
|
+
while (Date.now() < deadline) {
|
|
14942
|
+
attempts++;
|
|
14943
|
+
const { data, error } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
|
|
14944
|
+
if (!error && data?.success) {
|
|
14945
|
+
const found = findActiveAccount(data.accounts ?? [], platform3);
|
|
14946
|
+
if (found) {
|
|
14947
|
+
await logMcpToolInvocation({
|
|
14948
|
+
toolName: "wait_for_connection",
|
|
14949
|
+
status: "success",
|
|
14950
|
+
durationMs: Date.now() - startedAt,
|
|
14951
|
+
details: { platform: platform3, attempts, found: true }
|
|
14952
|
+
});
|
|
14953
|
+
if (format === "json") {
|
|
14954
|
+
return {
|
|
14955
|
+
content: [
|
|
14956
|
+
{
|
|
14957
|
+
type: "text",
|
|
14958
|
+
text: JSON.stringify(
|
|
14959
|
+
{
|
|
14960
|
+
connected: true,
|
|
14961
|
+
platform: found.platform,
|
|
14962
|
+
account_id: found.id,
|
|
14963
|
+
username: found.username,
|
|
14964
|
+
connected_at: found.created_at,
|
|
14965
|
+
attempts
|
|
14966
|
+
},
|
|
14967
|
+
null,
|
|
14968
|
+
2
|
|
14969
|
+
)
|
|
14970
|
+
}
|
|
14971
|
+
],
|
|
14972
|
+
isError: false
|
|
14973
|
+
};
|
|
14974
|
+
}
|
|
14975
|
+
return {
|
|
14976
|
+
content: [
|
|
14977
|
+
{
|
|
14978
|
+
type: "text",
|
|
14979
|
+
text: [
|
|
14980
|
+
`${found.platform} is connected.`,
|
|
14981
|
+
`Account: ${found.username || "(unnamed)"} (id=${found.id})`,
|
|
14982
|
+
`Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
|
|
14983
|
+
"Ready to call `schedule_post`."
|
|
14984
|
+
].join("\n")
|
|
14985
|
+
}
|
|
14986
|
+
],
|
|
14987
|
+
isError: false
|
|
14988
|
+
};
|
|
14989
|
+
}
|
|
14990
|
+
}
|
|
14991
|
+
const remaining = deadline - Date.now();
|
|
14992
|
+
if (remaining <= 0) break;
|
|
14993
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
|
|
14994
|
+
}
|
|
14995
|
+
await logMcpToolInvocation({
|
|
14996
|
+
toolName: "wait_for_connection",
|
|
14997
|
+
status: "error",
|
|
14998
|
+
durationMs: Date.now() - startedAt,
|
|
14999
|
+
details: { platform: platform3, attempts, found: false, reason: "timeout" }
|
|
15000
|
+
});
|
|
15001
|
+
const message = `${platform3} 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.`;
|
|
15002
|
+
if (format === "json") {
|
|
15003
|
+
return {
|
|
15004
|
+
content: [
|
|
15005
|
+
{
|
|
15006
|
+
type: "text",
|
|
15007
|
+
text: JSON.stringify(
|
|
15008
|
+
{
|
|
15009
|
+
connected: false,
|
|
15010
|
+
platform: platform3,
|
|
15011
|
+
attempts,
|
|
15012
|
+
timed_out: true,
|
|
15013
|
+
message
|
|
15014
|
+
},
|
|
15015
|
+
null,
|
|
15016
|
+
2
|
|
15017
|
+
)
|
|
15018
|
+
}
|
|
15019
|
+
],
|
|
15020
|
+
isError: true
|
|
15021
|
+
};
|
|
15022
|
+
}
|
|
15023
|
+
return {
|
|
15024
|
+
content: [{ type: "text", text: message }],
|
|
15025
|
+
isError: true
|
|
15026
|
+
};
|
|
15027
|
+
}
|
|
15028
|
+
);
|
|
15029
|
+
}
|
|
15030
|
+
|
|
14411
15031
|
// src/lib/register-tools.ts
|
|
14412
15032
|
function applyScopeEnforcement(server2, scopeResolver) {
|
|
14413
15033
|
const originalTool = server2.tool.bind(server2);
|
|
@@ -14437,7 +15057,7 @@ function applyScopeEnforcement(server2, scopeResolver) {
|
|
|
14437
15057
|
content: [
|
|
14438
15058
|
{
|
|
14439
15059
|
type: "text",
|
|
14440
|
-
text: `Permission denied: '${name}' requires scope '${requiredScope}'.
|
|
15060
|
+
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.`
|
|
14441
15061
|
}
|
|
14442
15062
|
],
|
|
14443
15063
|
isError: true
|
|
@@ -14513,21 +15133,25 @@ function registerAllTools(server2, options) {
|
|
|
14513
15133
|
registerDigestTools(server2);
|
|
14514
15134
|
registerBrandRuntimeTools(server2);
|
|
14515
15135
|
registerCarouselTools(server2);
|
|
15136
|
+
registerConnectionTools(server2);
|
|
15137
|
+
if (!options?.skipApps) {
|
|
15138
|
+
registerContentCalendarApp(server2);
|
|
15139
|
+
}
|
|
14516
15140
|
applyAnnotations(server2);
|
|
14517
15141
|
}
|
|
14518
15142
|
|
|
14519
15143
|
// src/prompts.ts
|
|
14520
|
-
import { z as
|
|
15144
|
+
import { z as z30 } from "zod";
|
|
14521
15145
|
function registerPrompts(server2) {
|
|
14522
15146
|
server2.prompt(
|
|
14523
15147
|
"create_weekly_content_plan",
|
|
14524
15148
|
"Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
|
|
14525
15149
|
{
|
|
14526
|
-
niche:
|
|
14527
|
-
platforms:
|
|
15150
|
+
niche: z30.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
|
|
15151
|
+
platforms: z30.string().optional().describe(
|
|
14528
15152
|
'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
|
|
14529
15153
|
),
|
|
14530
|
-
tone:
|
|
15154
|
+
tone: z30.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
|
|
14531
15155
|
},
|
|
14532
15156
|
({ niche, platforms, tone }) => {
|
|
14533
15157
|
const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
|
|
@@ -14569,8 +15193,8 @@ After building the plan, use \`save_content_plan\` to save it.`
|
|
|
14569
15193
|
"analyze_top_content",
|
|
14570
15194
|
"Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
|
|
14571
15195
|
{
|
|
14572
|
-
timeframe:
|
|
14573
|
-
platform:
|
|
15196
|
+
timeframe: z30.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
|
|
15197
|
+
platform: z30.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
|
|
14574
15198
|
},
|
|
14575
15199
|
({ timeframe, platform: platform3 }) => {
|
|
14576
15200
|
const period = timeframe || "30 days";
|
|
@@ -14607,10 +15231,10 @@ Format as a clear, actionable performance report.`
|
|
|
14607
15231
|
"repurpose_content",
|
|
14608
15232
|
"Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
|
|
14609
15233
|
{
|
|
14610
|
-
source:
|
|
15234
|
+
source: z30.string().describe(
|
|
14611
15235
|
"The source content to repurpose \u2014 a URL, transcript, or the content text itself"
|
|
14612
15236
|
),
|
|
14613
|
-
target_platforms:
|
|
15237
|
+
target_platforms: z30.string().optional().describe(
|
|
14614
15238
|
'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
|
|
14615
15239
|
)
|
|
14616
15240
|
},
|
|
@@ -14652,9 +15276,9 @@ For each piece, include the platform, format, character count, and suggested pos
|
|
|
14652
15276
|
"setup_brand_voice",
|
|
14653
15277
|
"Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
|
|
14654
15278
|
{
|
|
14655
|
-
brand_name:
|
|
14656
|
-
industry:
|
|
14657
|
-
website:
|
|
15279
|
+
brand_name: z30.string().describe("Your brand or business name"),
|
|
15280
|
+
industry: z30.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
|
|
15281
|
+
website: z30.string().optional().describe("Your website URL for context")
|
|
14658
15282
|
},
|
|
14659
15283
|
({ brand_name, industry, website }) => {
|
|
14660
15284
|
const industryContext = industry ? ` in the ${industry} space` : "";
|
|
@@ -15168,7 +15792,7 @@ var server = new McpServer({
|
|
|
15168
15792
|
version: MCP_VERSION
|
|
15169
15793
|
});
|
|
15170
15794
|
applyScopeEnforcement(server, getAuthenticatedScopes);
|
|
15171
|
-
registerAllTools(server);
|
|
15795
|
+
registerAllTools(server, { skipApps: true });
|
|
15172
15796
|
registerPrompts(server);
|
|
15173
15797
|
registerResources(server);
|
|
15174
15798
|
async function shutdown() {
|