|
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
|
|
isInitializeRequest,
|
|
|
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
|
|
|
import { tools, handleToolCall } from './tools.js';
|
|
|
|
|
import { getManifest, getOpenApiSpec } from './manifest.js';
|
|
|
|
|
import { getManifest, getOpenApiSpec, getOpenApiSpecMail, getOpenApiSpecSocial } from './manifest.js';
|
|
|
|
|
import { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
|
|
|
|
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
|
|
|
|
import { meterMiddleware, type Customer } from './billing/middleware.js';
|
|
|
|
|
@@ -46,6 +46,7 @@ const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protect
|
|
|
|
|
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
|
|
|
|
'https://squaremcp.com',
|
|
|
|
|
'https://www.squaremcp.com',
|
|
|
|
|
'https://tiktok.squaremcp.com',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
type PilotRequestBody = {
|
|
|
|
|
@@ -73,6 +74,15 @@ function sanitizeField(value: unknown) {
|
|
|
|
|
return String(value ?? '').trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value: string) {
|
|
|
|
|
return value
|
|
|
|
|
.replaceAll('&', '&')
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
.replaceAll("'", ''');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPilotRequestBody(body: Record<string, unknown>): PilotRequestBody {
|
|
|
|
|
return {
|
|
|
|
|
name: sanitizeField(body.name),
|
|
|
|
|
@@ -157,6 +167,103 @@ async function appendPilotRequestToVault(requestId: string, body: PilotRequestBo
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TIKTOK_CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY ?? '';
|
|
|
|
|
const TIKTOK_CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET ?? '';
|
|
|
|
|
const TIKTOK_REDIRECT_URI = process.env.TIKTOK_REDIRECT_URI ?? 'https://tiktok.squaremcp.com/auth/tiktok/callback';
|
|
|
|
|
const TIKTOK_DEFAULT_SCOPE = 'user.info.basic,video.publish';
|
|
|
|
|
|
|
|
|
|
function getTikTokAuthorizeUrl(state?: string, scope = TIKTOK_DEFAULT_SCOPE) {
|
|
|
|
|
const url = new URL('https://www.tiktok.com/v2/auth/authorize/');
|
|
|
|
|
url.searchParams.set('client_key', TIKTOK_CLIENT_KEY);
|
|
|
|
|
url.searchParams.set('response_type', 'code');
|
|
|
|
|
url.searchParams.set('scope', scope);
|
|
|
|
|
url.searchParams.set('redirect_uri', TIKTOK_REDIRECT_URI);
|
|
|
|
|
if (state) {
|
|
|
|
|
url.searchParams.set('state', state);
|
|
|
|
|
}
|
|
|
|
|
return url.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderTikTokCallbackHtml(args: {
|
|
|
|
|
title: string;
|
|
|
|
|
status: 'success' | 'error';
|
|
|
|
|
message: string;
|
|
|
|
|
details?: string[];
|
|
|
|
|
}) {
|
|
|
|
|
const accent = args.status === 'success' ? '#22c55e' : '#ef4444';
|
|
|
|
|
const details = (args.details ?? [])
|
|
|
|
|
.map((line) => `<li>${escapeHtml(line)}</li>`)
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
return `<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>${escapeHtml(args.title)}</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { color-scheme: dark; }
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
background: radial-gradient(circle at top, rgba(14,99,246,0.18), transparent 30%), #09090f;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-family: Inter, system-ui, sans-serif;
|
|
|
|
|
padding: 24px;
|
|
|
|
|
}
|
|
|
|
|
.card {
|
|
|
|
|
width: min(720px, 100%);
|
|
|
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
|
|
|
border-radius: 28px;
|
|
|
|
|
background: rgba(18,18,31,0.94);
|
|
|
|
|
box-shadow: 0 30px 80px rgba(0,0,0,0.35);
|
|
|
|
|
padding: 32px;
|
|
|
|
|
}
|
|
|
|
|
.eyebrow {
|
|
|
|
|
color: ${accent};
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
}
|
|
|
|
|
h1 {
|
|
|
|
|
margin: 0 0 12px;
|
|
|
|
|
font-size: 34px;
|
|
|
|
|
line-height: 1.05;
|
|
|
|
|
}
|
|
|
|
|
p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #cbd5e1;
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
line-height: 1.55;
|
|
|
|
|
}
|
|
|
|
|
ul {
|
|
|
|
|
margin: 24px 0 0;
|
|
|
|
|
padding-left: 20px;
|
|
|
|
|
color: #e2e8f0;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
code {
|
|
|
|
|
color: #93c5fd;
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main class="card">
|
|
|
|
|
<div class="eyebrow">TikTok Login Kit</div>
|
|
|
|
|
<h1>${escapeHtml(args.title)}</h1>
|
|
|
|
|
<p>${escapeHtml(args.message)}</p>
|
|
|
|
|
${details ? `<ul>${details}</ul>` : ''}
|
|
|
|
|
</main>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Auth middleware ─────────────────────────────────────────────────────────
|
|
|
|
|
const API_KEY = process.env.MCP_API_KEY;
|
|
|
|
|
|
|
|
|
|
@@ -361,6 +468,152 @@ app.post('/oauth/token', async (req, res) => {
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── TikTok Login Kit + Content Posting auth flow ───────────────────────────
|
|
|
|
|
app.get('/auth/tiktok/start', async (req, res) => {
|
|
|
|
|
if (!TIKTOK_CLIENT_KEY) {
|
|
|
|
|
res.status(500).send(renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok login is not configured',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: 'SquareMCP is missing the TikTok client configuration required to start Login Kit.',
|
|
|
|
|
details: ['Set TIKTOK_CLIENT_KEY before using this route.'],
|
|
|
|
|
}));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state = req.query.state as string | undefined;
|
|
|
|
|
const scope = req.query.scope as string | undefined;
|
|
|
|
|
res.redirect(getTikTokAuthorizeUrl(state, scope || TIKTOK_DEFAULT_SCOPE));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/auth/tiktok/callback', async (req, res) => {
|
|
|
|
|
const code = req.query.code as string | undefined;
|
|
|
|
|
const state = req.query.state as string | undefined;
|
|
|
|
|
const error = req.query.error as string | undefined;
|
|
|
|
|
const errorDescription = req.query.error_description as string | undefined;
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
res
|
|
|
|
|
.status(400)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok authorization failed',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: errorDescription || error,
|
|
|
|
|
details: state ? [`State: ${state}`] : undefined,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!code) {
|
|
|
|
|
res
|
|
|
|
|
.status(400)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok authorization failed',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: 'TikTok did not provide an authorization code.',
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!TIKTOK_CLIENT_KEY || !TIKTOK_CLIENT_SECRET) {
|
|
|
|
|
res
|
|
|
|
|
.status(500)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok login is not configured',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: 'SquareMCP received the callback, but the TikTok client credentials are missing on the server.',
|
|
|
|
|
details: [
|
|
|
|
|
'Set TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET on the Hermes deployment.',
|
|
|
|
|
`Redirect URI configured: ${TIKTOK_REDIRECT_URI}`,
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const body = new URLSearchParams({
|
|
|
|
|
client_key: TIKTOK_CLIENT_KEY,
|
|
|
|
|
client_secret: TIKTOK_CLIENT_SECRET,
|
|
|
|
|
code,
|
|
|
|
|
grant_type: 'authorization_code',
|
|
|
|
|
redirect_uri: TIKTOK_REDIRECT_URI,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tokenRes = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
|
|
|
},
|
|
|
|
|
body: body.toString(),
|
|
|
|
|
signal: AbortSignal.timeout(15000),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tokenJson = await tokenRes.json() as Record<string, unknown>;
|
|
|
|
|
const tokenError = tokenJson.error as string | undefined;
|
|
|
|
|
const tokenErrorDescription =
|
|
|
|
|
(tokenJson.error_description as string | undefined) ||
|
|
|
|
|
(tokenJson.description as string | undefined) ||
|
|
|
|
|
(tokenJson.message as string | undefined);
|
|
|
|
|
|
|
|
|
|
if (!tokenRes.ok || tokenError) {
|
|
|
|
|
res
|
|
|
|
|
.status(400)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok token exchange failed',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: tokenErrorDescription || tokenError || `Token endpoint returned HTTP ${tokenRes.status}.`,
|
|
|
|
|
details: [
|
|
|
|
|
`Redirect URI used: ${TIKTOK_REDIRECT_URI}`,
|
|
|
|
|
...(state ? [`State: ${state}`] : []),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const accessToken = String(tokenJson.access_token ?? '');
|
|
|
|
|
const expiresIn = String(tokenJson.expires_in ?? '');
|
|
|
|
|
const openId = String(tokenJson.open_id ?? '');
|
|
|
|
|
const scope = String(tokenJson.scope ?? '');
|
|
|
|
|
|
|
|
|
|
res
|
|
|
|
|
.status(200)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok account connected',
|
|
|
|
|
status: 'success',
|
|
|
|
|
message:
|
|
|
|
|
'SquareMCP successfully completed the TikTok Login Kit callback and exchanged the authorization code for a user access token.',
|
|
|
|
|
details: [
|
|
|
|
|
`Open ID: ${openId || 'not returned'}`,
|
|
|
|
|
`Scopes: ${scope || 'not returned'}`,
|
|
|
|
|
`Expires in: ${expiresIn || 'not returned'} seconds`,
|
|
|
|
|
`Access token captured: ${accessToken ? `${accessToken.slice(0, 8)}...` : 'no'}`,
|
|
|
|
|
'Next step: use this token with /api/connect/tiktok or set TIKTOK_DEFAULT_ACCESS_TOKEN for server-side publishing.',
|
|
|
|
|
...(state ? [`State: ${state}`] : []),
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res
|
|
|
|
|
.status(500)
|
|
|
|
|
.send(
|
|
|
|
|
renderTikTokCallbackHtml({
|
|
|
|
|
title: 'TikTok token exchange failed',
|
|
|
|
|
status: 'error',
|
|
|
|
|
message: err instanceof Error ? err.message : 'Unknown error exchanging the TikTok authorization code.',
|
|
|
|
|
details: [`Redirect URI used: ${TIKTOK_REDIRECT_URI}`],
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
|
|
|
|
|
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
|
|
|
|
|
|
|
|
|
|
@@ -522,6 +775,14 @@ app.get('/openapi.json', (_req, res) => {
|
|
|
|
|
res.json(getOpenApiSpec(SERVER_URL));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/openapi-mail.json', (_req, res) => {
|
|
|
|
|
res.json(getOpenApiSpecMail(SERVER_URL));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/openapi-social.json', (_req, res) => {
|
|
|
|
|
res.json(getOpenApiSpecSocial(SERVER_URL));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Obsidian REST API (ChatGPT Actions) ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function parseToolResult(result: { content: Array<{ type: string; text: string }> }): unknown {
|
|
|
|
|
@@ -699,7 +960,7 @@ app.post('/api/connect/email', meterMiddleware, async (req, res) => {
|
|
|
|
|
app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
|
|
|
|
const customer = (req as unknown as { customer: Customer }).customer;
|
|
|
|
|
const platform = req.params.platform as Platform;
|
|
|
|
|
const { accessToken, refreshToken, expiresAt, scope } = req.body as Record<string, string>;
|
|
|
|
|
const { accessToken, refreshToken, expiresAt, scope, pageId } = req.body as Record<string, string>;
|
|
|
|
|
|
|
|
|
|
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook'];
|
|
|
|
|
if (!validPlatforms.includes(platform)) {
|
|
|
|
|
@@ -712,12 +973,28 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await storeCredential(customer.id, platform, {
|
|
|
|
|
const credentials: {
|
|
|
|
|
accessToken: string;
|
|
|
|
|
refreshToken?: string;
|
|
|
|
|
expiresAt?: number;
|
|
|
|
|
scope?: string;
|
|
|
|
|
pageId?: string;
|
|
|
|
|
} = {
|
|
|
|
|
accessToken,
|
|
|
|
|
refreshToken,
|
|
|
|
|
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
|
|
|
|
|
scope,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (platform === 'facebook') {
|
|
|
|
|
if (!pageId) {
|
|
|
|
|
res.status(400).json({ error: 'page_id_required' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
credentials.pageId = pageId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await storeCredential(customer.id, platform, credentials);
|
|
|
|
|
|
|
|
|
|
res.json({ connected: true, platform });
|
|
|
|
|
});
|
|
|
|
|
|