feat: split OpenAPI schema into Mail + Social for ChatGPT

- Hermes Mail: 9 ops (Obsidian + Email)
- Hermes Social: 25 ops (LinkedIn, TikTok, Instagram, FB, Twitter, Telegram, WhatsApp, Discord)
- Full schema still available at /openapi.json
This commit is contained in:
Garfield
2026-05-11 22:23:37 -04:00
parent ecdf332b78
commit c6b0697e8c
6 changed files with 359 additions and 4 deletions

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih

View File

@@ -0,0 +1 @@
tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn

View File

@@ -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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 });
});

View File

@@ -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<string, unknown>, allowed: Set<string>): Record<string, unknown> {
const paths = fullSpec.paths as Record<string, unknown>;
const filtered: Record<string, unknown> = {};
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',