diff --git a/product/site/tiktok/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt b/product/site/tiktok/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt new file mode 100644 index 0000000..50b3563 --- /dev/null +++ b/product/site/tiktok/tiktokJLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM \ No newline at end of file diff --git a/product/site/tiktok/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt b/product/site/tiktok/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt new file mode 100644 index 0000000..079a668 --- /dev/null +++ b/product/site/tiktok/tiktokXbaTJRvDkwUNzhEXou9SsogGyiDkQshF.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF \ No newline at end of file diff --git a/product/site/tiktok/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt b/product/site/tiktok/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt new file mode 100644 index 0000000..e86338f --- /dev/null +++ b/product/site/tiktok/tiktokkFNJHjzDuzvGIlXnK4MaGw3MSluybOih.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih \ No newline at end of file diff --git a/product/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt b/product/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt new file mode 100644 index 0000000..00e3712 --- /dev/null +++ b/product/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt @@ -0,0 +1 @@ +tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e22e3d4..f72f797 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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): 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) => `
  • ${escapeHtml(line)}
  • `) + .join(''); + + return ` + + + + + ${escapeHtml(args.title)} + + + +
    +
    TikTok Login Kit
    +

    ${escapeHtml(args.title)}

    +

    ${escapeHtml(args.message)}

    + ${details ? `` : ''} +
    + +`; +} + // ── 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; + 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(); @@ -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; + const { accessToken, refreshToken, expiresAt, scope, pageId } = req.body as Record; 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 }); }); diff --git a/src/manifest.ts b/src/manifest.ts index 85fef12..f0cdb3b 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -720,6 +720,80 @@ export function getOpenApiSpec(serverUrl: string) { }; } +const MAIL_PATHS = new Set([ + '/api/obsidian/search', + '/api/obsidian/note', + '/api/obsidian/note/append', + '/api/obsidian/sync', + '/api/email/profile', + '/api/email/search', + '/api/email/read', + '/api/email/send', +]); + +const SOCIAL_PATHS = new Set([ + '/api/whatsapp/send', + '/api/linkedin/profile', + '/api/linkedin/post', + '/api/linkedin/video', + '/api/telegram/message', + '/api/telegram/updates', + '/api/discord/guilds', + '/api/discord/message', + '/api/discord/messages', + '/api/instagram/profile', + '/api/instagram/media', + '/api/instagram/post', + '/api/instagram/reel', + '/api/facebook/page', + '/api/facebook/posts', + '/api/facebook/post', + '/api/facebook/photo', + '/api/facebook/video', + '/api/twitter/search', + '/api/twitter/user', + '/api/twitter/tweets', + '/api/twitter/video', + '/api/tiktok/profile', + '/api/tiktok/videos', + '/api/tiktok/video', +]); + +function filterPaths(fullSpec: Record, allowed: Set): Record { + const paths = fullSpec.paths as Record; + const filtered: Record = {}; + for (const [key, value] of Object.entries(paths)) { + if (allowed.has(key)) filtered[key] = value; + } + return filtered; +} + +export function getOpenApiSpecMail(serverUrl: string) { + const full = getOpenApiSpec(serverUrl); + return { + ...full, + info: { + ...full.info, + title: 'Hermes Mail', + description: 'Email operations across multiple accounts and Obsidian vault access (search, read, append, update notes).', + }, + paths: filterPaths(full, MAIL_PATHS), + }; +} + +export function getOpenApiSpecSocial(serverUrl: string) { + const full = getOpenApiSpec(serverUrl); + return { + ...full, + info: { + ...full.info, + title: 'Hermes Social', + description: 'Social media publishing and analytics: LinkedIn, TikTok, Instagram, Facebook, Twitter/X, Telegram, WhatsApp, Discord.', + }, + paths: filterPaths(full, SOCIAL_PATHS), + }; +} + const ACCOUNT_PARAM_SCHEMA = { account: { type: 'string',