#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import express from 'express';
import cors from 'cors';
const APP_NAME = 'zocialeye-mcp';
const APP_VERSION = '1.0.0';
const API_BASE_URL = process.env.ZOCIALEYE_API_URL || 'https://apix.zocialeye.com/api/v1';
const PORT = parseInt(process.env.PORT || '3000', 10);
// Simple logger
function log(level: string, message: string, ...args: any[]) {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] ${message}`, ...args);
}
// Get default date range (last 7 days)
function getDefaultDateRange() {
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgo = now - (7 * 24 * 60 * 60);
return { date_start: sevenDaysAgo, date_end: now };
}
// Convert date to Unix timestamp
function toUnixTimestamp(dateInput?: string | number): number | undefined {
if (!dateInput) return undefined;
if (typeof dateInput === 'number') return dateInput;
const date = new Date(dateInput);
if (isNaN(date.getTime())) {
throw new Error(`Invalid date: ${dateInput}. Use ISO 8601 format.`);
}
return Math.floor(date.getTime() / 1000);
}
// Create MCP server instance
function createMCPServer(apiKey: string, campaignId: string) {
const server = new Server(
{ name: APP_NAME, version: APP_VERSION },
{ capabilities: { tools: {} } }
);
// Define tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_campaign_wordcloud',
description: 'Get word cloud data for a campaign',
inputSchema: {
type: 'object',
properties: {
date_start: { type: 'string', description: 'Start date in ISO 8601 format (default: 7 days ago)' },
date_end: { type: 'string', description: 'End date in ISO 8601 format (default: now)' },
group_by: {
type: 'string',
enum: ['category', 'group', 'sentiment', 'keywords', 'all'],
},
filter: { type: 'object' },
},
},
},
{
name: 'get_campaign_messages',
description: 'Get messages/posts for a campaign',
inputSchema: {
type: 'object',
properties: {
date_start: { type: 'string', description: 'Start date in ISO 8601 format (default: 7 days ago)' },
date_end: { type: 'string', description: 'End date in ISO 8601 format (default: now)' },
total: { type: 'number', maximum: 50 },
from: { type: 'number', default: 0 },
sort_by: { type: 'object' },
group_by: {
type: 'string',
enum: ['category', 'group', 'sentiment', 'keywords', 'channel', 'all'],
},
filter: { type: 'object' },
},
},
},
{
name: 'get_campaign_summary',
description: 'Get summary statistics for a campaign',
inputSchema: {
type: 'object',
properties: {
date_start: { type: 'string', description: 'Start date in ISO 8601 format (default: 7 days ago)' },
date_end: { type: 'string', description: 'End date in ISO 8601 format (default: now)' },
duration: { type: 'string', enum: ['hour', 'day'], default: 'day' },
group_by: {
type: 'string',
enum: ['category', 'group', 'sentiment', 'keyword', 'all'],
},
filter: { type: 'object' },
},
},
},
{
name: 'get_campaign_influencers',
description: 'Get top influencers for a campaign',
inputSchema: {
type: 'object',
properties: {
date_start: { type: 'string', description: 'Start date in ISO 8601 format (default: 7 days ago)' },
date_end: { type: 'string', description: 'End date in ISO 8601 format (default: now)' },
group_by: {
type: 'string',
enum: ['category', 'group', 'sentiment', 'keyword', 'all'],
},
filter: { type: 'object' },
},
},
},
{
name: 'get_campaign_categories',
description: 'Get all categories for a campaign',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_campaign_keywords',
description: 'Get all keywords for a campaign',
inputSchema: { type: 'object', properties: {} },
},
] as Tool[],
}));
// Make API request
async function apiRequest(endpoint: string, params: any = {}, method = 'POST') {
try {
const response = await axios({
method,
url: `${API_BASE_URL}${endpoint}`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
...(method === 'POST' ? { data: params } : { params }),
timeout: 30000,
});
return response.data;
} catch (error: any) {
throw new Error(`API Error: ${error.response?.status} ${error.response?.statusText || error.message}`);
}
}
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args = {} } = request.params;
const defaults = getDefaultDateRange();
let data;
switch (name) {
case 'get_campaign_wordcloud': {
const params: any = {
date_start: toUnixTimestamp((args as any).date_start) || defaults.date_start,
date_end: toUnixTimestamp((args as any).date_end) || defaults.date_end,
};
if ((args as any).group_by) params.group_by = (args as any).group_by;
if ((args as any).filter) params.filter = (args as any).filter;
data = await apiRequest(`/campaigns/${campaignId}/wordcloud`, params);
break;
}
case 'get_campaign_messages': {
const params: any = {
date_start: toUnixTimestamp((args as any).date_start) || defaults.date_start,
date_end: toUnixTimestamp((args as any).date_end) || defaults.date_end,
};
if ((args as any).total) params.total = (args as any).total;
if ((args as any).from) params.from = (args as any).from;
if ((args as any).sort_by) params.sort_by = (args as any).sort_by;
if ((args as any).group_by) params.group_by = (args as any).group_by;
if ((args as any).filter) params.filter = (args as any).filter;
data = await apiRequest(`/campaigns/${campaignId}/messages`, params);
break;
}
case 'get_campaign_summary': {
const params: any = {
date_start: toUnixTimestamp((args as any).date_start) || defaults.date_start,
date_end: toUnixTimestamp((args as any).date_end) || defaults.date_end,
duration: (args as any).duration || 'day',
};
if ((args as any).group_by) params.group_by = (args as any).group_by;
if ((args as any).filter) params.filter = (args as any).filter;
data = await apiRequest(`/campaigns/${campaignId}/summary`, params);
break;
}
case 'get_campaign_influencers': {
const params: any = {
date_start: toUnixTimestamp((args as any).date_start) || defaults.date_start,
date_end: toUnixTimestamp((args as any).date_end) || defaults.date_end,
};
if ((args as any).group_by) params.group_by = (args as any).group_by;
if ((args as any).filter) params.filter = (args as any).filter;
data = await apiRequest(`/campaigns/${campaignId}/influencers`, params);
break;
}
case 'get_campaign_categories':
data = await apiRequest(`/campaigns/${campaignId}/categories`, {}, 'GET');
break;
case 'get_campaign_keywords':
data = await apiRequest(`/campaigns/${campaignId}/keywords`, {}, 'GET');
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
};
} catch (error: any) {
return {
content: [{ type: 'text' as const, text: `Error: ${error.message}` }],
isError: true,
};
}
});
return server;
}
// Express app
const app = express();
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: APP_NAME, version: APP_VERSION });
});
// Store MCP servers per campaign+apiKey
const mcpServers = new Map<string, { server: Server; transport: StreamableHTTPServerTransport }>();
// Streamable HTTP endpoint
app.use('/:campaignId/mcp', async (req, res) => {
const campaignId = req.params.campaignId;
const authHeader = req.headers.authorization;
// Validate campaign ID is numeric
if (!/^\d+$/.test(campaignId)) {
return res.status(401).json({
error: 'Invalid campaign ID. Must be numeric.'
});
}
// Extract API key from Authorization header
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Missing Authorization header. Use: Authorization: Bearer <your-api-key>'
});
}
const apiKey = authHeader.substring(7).trim();
if (!apiKey) {
return res.status(401).json({ error: 'API key cannot be empty' });
}
log('INFO', `MCP request: campaign=${campaignId}, method=${req.method}`);
try {
// Create server instance if not exists
const key = `${campaignId}:${apiKey}`;
if (!mcpServers.has(key)) {
const mcpServer = createMCPServer(apiKey, campaignId);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
});
await mcpServer.connect(transport);
mcpServers.set(key, { server: mcpServer, transport });
log('INFO', `Created MCP server instance for campaign=${campaignId}`);
}
// Handle request with transport
const { transport } = mcpServers.get(key)!;
await transport.handleRequest(req, res, req.body);
} catch (error: any) {
log('ERROR', `Request error: ${error.message}`);
if (!res.headersSent) {
res.status(500).json({ error: 'Request failed' });
}
}
});
// Start server
app.listen(PORT, () => {
log('INFO', `${APP_NAME} v${APP_VERSION} running on port ${PORT}`);
log('INFO', `Endpoint: http://localhost:${PORT}/{campaign_id}/mcp`);
log('INFO', `Transport: Streamable HTTP`);
});