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:
@@ -0,0 +1 @@
|
|||||||
|
tiktok-developers-site-verification=JLZJ5wt0TEQdq2RTHGAZtY9ofrtPFBeM
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tiktok-developers-site-verification=XbaTJRvDkwUNzhEXou9SsogGyiDkQshF
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tiktok-developers-site-verification=kFNJHjzDuzvGIlXnK4MaGw3MSluybOih
|
||||||
1
product/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt
Normal file
1
product/tiktokebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
tiktok-developers-site-verification=ebJaRSVgFOD0hQE1YXb5XhZ3IvKw4LVn
|
||||||
285
src/index.ts
285
src/index.ts
@@ -11,7 +11,7 @@ import {
|
|||||||
isInitializeRequest,
|
isInitializeRequest,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { tools, handleToolCall } from './tools.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 { routeWhatsAppWebhook, registerWhatsAppNumber, type RoutedWebhookEvent } from './multitenancy/webhook-router.js';
|
||||||
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
import { storeCredential, type Platform } from './multitenancy/credential-store.js';
|
||||||
import { meterMiddleware, type Customer } from './billing/middleware.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([
|
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
||||||
'https://squaremcp.com',
|
'https://squaremcp.com',
|
||||||
'https://www.squaremcp.com',
|
'https://www.squaremcp.com',
|
||||||
|
'https://tiktok.squaremcp.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type PilotRequestBody = {
|
type PilotRequestBody = {
|
||||||
@@ -73,6 +74,15 @@ function sanitizeField(value: unknown) {
|
|||||||
return String(value ?? '').trim();
|
return String(value ?? '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
function getPilotRequestBody(body: Record<string, unknown>): PilotRequestBody {
|
function getPilotRequestBody(body: Record<string, unknown>): PilotRequestBody {
|
||||||
return {
|
return {
|
||||||
name: sanitizeField(body.name),
|
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 ─────────────────────────────────────────────────────────
|
// ── Auth middleware ─────────────────────────────────────────────────────────
|
||||||
const API_KEY = process.env.MCP_API_KEY;
|
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) ────────────────────────────
|
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
|
||||||
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
|
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||||
|
|
||||||
@@ -522,6 +775,14 @@ app.get('/openapi.json', (_req, res) => {
|
|||||||
res.json(getOpenApiSpec(SERVER_URL));
|
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) ────────────────────────────────────
|
// ── Obsidian REST API (ChatGPT Actions) ────────────────────────────────────
|
||||||
|
|
||||||
function parseToolResult(result: { content: Array<{ type: string; text: string }> }): unknown {
|
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) => {
|
app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
||||||
const customer = (req as unknown as { customer: Customer }).customer;
|
const customer = (req as unknown as { customer: Customer }).customer;
|
||||||
const platform = req.params.platform as Platform;
|
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'];
|
const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'facebook'];
|
||||||
if (!validPlatforms.includes(platform)) {
|
if (!validPlatforms.includes(platform)) {
|
||||||
@@ -712,12 +973,28 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await storeCredential(customer.id, platform, {
|
const credentials: {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
scope?: string;
|
||||||
|
pageId?: string;
|
||||||
|
} = {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
|
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined,
|
||||||
scope,
|
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 });
|
res.json({ connected: true, platform });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
const ACCOUNT_PARAM_SCHEMA = {
|
||||||
account: {
|
account: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|||||||
Reference in New Issue
Block a user