Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
313 lines (281 loc) · 11.9 KB
/
Copy pathserver.js
File metadata and controls
313 lines (281 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
import { createHash } from 'node:crypto';
import express from 'express';
import cors from 'cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { GROUPS, resolveCall } from './src/groups.js';
import { currentTools, currentGroupedTools, catalogStatus, startLiveCatalog } from './src/liveCatalog.js';
import { SERVER_IDENTITY, enrichTool, buildStructuredResult } from './src/toolMeta.js';
const app = express();
const PORT = process.env.PORT || 3001;
const API_BASE_URL = process.env.API_BASE_URL || 'https://api.socialapis.io';
const SERVER_VERSION = '2.2.0';
// Upstream scraping calls can take up to ~85s (api-scraping axios timeout).
// Give the backend fetch a little more so we return the API's own error
// instead of an opaque abort.
const BACKEND_TIMEOUT_MS = 90_000;
app.use(cors({
// Browser-based MCP clients (e.g. the MCP Inspector) need these exposed
// and allowed. Mcp-Session-Id is unused in stateless mode but harmless.
exposedHeaders: ['Mcp-Session-Id'],
allowedHeaders: [
'Content-Type', 'Accept', 'Authorization', 'x-api-key', 'x-api-token',
'Mcp-Session-Id', 'Mcp-Protocol-Version', 'Last-Event-ID',
],
}));
app.use(express.json({ limit: '1mb' }));
// Legacy shape for GET /tools. npm client 1.1.0 and older build their tool
// list from this endpoint but never return structuredContent, and MCP
// clients reject tools that declare an outputSchema without it — so this
// shape must not gain outputSchema. Newer clients request ?format=full.
const publicTools = () => currentTools().map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
// Full definitions (title, annotations, outputSchema) for the hosted /mcp
// endpoint and npm client >= 1.2.0: the 12 grouped tools (src/groups.js).
// The 50 per-endpoint names above stay callable through resolveCall.
// Built from the live catalog overlay (src/liveCatalog.js): prices,
// descriptions and parameters follow the API; names never change. Memoized
// per definitions object, so it's rebuilt only after a catalog change.
let fullCache = { from: null, tools: [] };
const fullTools = () => {
const grouped = currentGroupedTools();
if (fullCache.from !== grouped) fullCache = { from: grouped, tools: grouped.map(enrichTool) };
return fullCache.tools;
};
const groupNames = new Set(GROUPS.map((g) => g.name));
// One JSON line per event, prefixed "[mcp]" so it's easy to grep:
// docker logs mcp-server --since 24h 2>&1 | grep '^\[mcp\]'
// Never log arguments (they can carry URLs and names) or the API key. `key`
// is a short irreversible fingerprint, enough to count distinct users.
const keyFingerprint = (key) => (key ? createHash('sha256').update(key).digest('hex').slice(0, 10) : null);
function logEvent(fields) {
console.log(`[mcp] ${JSON.stringify({ t: new Date().toISOString(), ...fields })}`);
}
// Tell the API this call came through MCP, so its request log can show MCP
// usage per account (transport and client name, nothing from the call).
// Header values must be printable ASCII.
function clientHeaders(ctx) {
const clean = (v) => String(v).replace(/[^\x20-\x7e]/g, '').slice(0, 120);
return {
'x-socialapis-client': 'mcp',
'x-socialapis-mcp-via': ctx.via === 'npm' ? 'npm' : 'hosted',
...(ctx.client ? { 'x-socialapis-mcp-client': clean(ctx.client) } : {}),
};
}
function creditsFrom(response, data) {
const header = Number(response.headers.get('x-socialapis-credits-deducted'));
if (Number.isFinite(header) && header > 0) return header;
const meta = data && !Array.isArray(data) && typeof data === 'object' ? data.meta : undefined;
return Number.isInteger(meta?.creditsCharged) ? meta.creditsCharged : null;
}
/**
* Call the SocialAPIs REST backend for one tool invocation.
* Shared by the legacy /proxy route (used by the npm stdio client) and the
* hosted /mcp endpoint, so both paths hit the API identically. Accepts a
* grouped tool name with an `action` argument, or one of the 50 per-endpoint names.
*
* @returns {{ status: number, ok: boolean, data: any }}
*/
async function callBackend(toolName, rawArgs, apiKey, ctx = {}) {
const started = Date.now();
const isGrouped = groupNames.has(toolName);
const base = {
event: 'call',
via: ctx.via,
client: ctx.client || null,
tool: toolName,
action: isGrouped ? (rawArgs?.action ?? null) : null,
legacy: !isGrouped,
key: keyFingerprint(apiKey),
};
const resolved = resolveCall(toolName, rawArgs, currentTools());
if (resolved.error) {
logEvent({ ...base, status: 400, error: 'invalid_call', ms: Date.now() - started });
return { status: 400, ok: false, data: { error: resolved.error } };
}
const { tool: toolDef, args } = resolved;
const url = new URL(toolDef.endpoint, API_BASE_URL);
if (toolDef.method === 'GET' && args) {
for (const [key, value] of Object.entries(args)) {
if (value !== undefined && value !== null) url.searchParams.append(key, String(value));
}
}
let response;
try {
response = await fetch(url.toString(), {
method: toolDef.method,
headers: { 'x-api-token': apiKey, 'Content-Type': 'application/json', ...clientHeaders(ctx) },
body: toolDef.method === 'POST' ? JSON.stringify(args ?? {}) : undefined,
signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS),
});
} catch (error) {
logEvent({ ...base, endpoint: url.pathname, status: 0, error: error.name || 'fetch_failed', ms: Date.now() - started });
throw error;
}
let data;
try {
data = await response.json();
} catch {
data = { error: `Backend returned non-JSON response (HTTP ${response.status})` };
}
logEvent({
...base,
endpoint: url.pathname,
status: response.status,
credits: response.ok ? creditsFrom(response, data) : 0,
ms: Date.now() - started,
});
return { status: response.status, ok: response.ok, data };
}
/**
* Pull the caller's SocialAPIs key from the request. Accepts the same
* header as the REST API (x-api-token), the legacy proxy header
* (x-api-key), or a standard bearer token — remote MCP clients differ in
* which of these they let users configure.
*/
function extractApiKey(req) {
const auth = req.headers.authorization;
if (auth && /^Bearer\s+/i.test(auth)) return auth.replace(/^Bearer\s+/i, '').trim();
return req.headers['x-api-token'] || req.headers['x-api-key'] || null;
}
/** Build a fresh MCP server bound to one caller's API key. */
function createMcpServer(apiKey, ctx) {
const server = new Server(
{ ...SERVER_IDENTITY, version: SERVER_VERSION },
{ capabilities: { tools: {} } },
);
// Tool discovery is public (same data as GET /tools) so directories and
// clients can list tools before the user has entered a key. A server is
// created per request (stateless), so each list reflects the current
// catalog overlay; no list_changed notification is needed.
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: fullTools() }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!apiKey) {
logEvent({ event: 'call', via: ctx.via, client: ctx.client, tool: name, status: 401, error: 'missing_key' });
return {
isError: true,
content: [{
type: 'text',
text: 'Missing SocialAPIs API key. Send it as "Authorization: Bearer <key>" '
+ '(or the "x-api-token" header). Get a free key at https://socialapis.io/dashboard',
}],
};
}
try {
const { ok, data } = await callBackend(name, args, apiKey, ctx);
// Text content stays for clients that ignore structured output.
// structuredContent only on success: errors carry isError and are
// exempt from outputSchema validation.
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
...(ok ? { structuredContent: buildStructuredResult(data) } : { isError: true }),
};
} catch (error) {
return {
isError: true,
content: [{ type: 'text', text: JSON.stringify({ error: error.message, tool: name }) }],
};
}
});
return server;
}
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'socialapis-mcp-server',
version: SERVER_VERSION,
tools: fullTools().length,
actions: currentTools().length,
catalog: catalogStatus(),
});
});
// List available tools (used by the npm stdio client)
app.get('/tools', (req, res) => {
res.json({ tools: req.query.format === 'full' ? fullTools() : publicTools() });
});
// Legacy proxy endpoint used by the npm stdio client (mcp-wrapper.js)
app.post('/proxy', async (req, res) => {
try {
const { tool, arguments: args } = req.body;
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
error: 'Missing x-api-key header',
message: 'Please provide your SocialAPIs API key in the x-api-key header',
});
}
const ctx = { via: 'npm', client: req.headers['x-mcp-client'] || 'npm (client <2.0.2)' };
const { status, ok, data } = await callBackend(tool, args, apiKey, ctx);
res.status(status).json({ success: ok, data });
} catch (error) {
console.error('❌ Proxy error:', error);
res.status(500).json({ error: error.message || 'Internal server error' });
}
});
// Hosted MCP endpoint (Streamable HTTP, stateless). A fresh server and
// transport per request: no session state to share across PM2/containers,
// and each request carries its own API key.
app.post('/mcp', async (req, res) => {
const ua = req.headers['user-agent'] || null;
// Stateless: clientInfo only arrives on `initialize`, so log it there with
// the user agent; later calls log the user agent, which maps back to it.
for (const msg of Array.isArray(req.body) ? req.body : [req.body]) {
if (msg?.method === 'initialize') {
const info = msg.params?.clientInfo || {};
logEvent({ event: 'initialize', via: 'mcp', client: info.name || null, clientVersion: info.version || null, protocol: msg.params?.protocolVersion || null, ua });
}
}
const server = createMcpServer(extractApiKey(req), { via: 'mcp', client: ua });
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
// Plain JSON responses instead of SSE — simpler behind nginx/Cloudflare
// and enough for request/response tool calls.
enableJsonResponse: true,
});
res.on('close', () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('❌ MCP error:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
id: null,
});
}
}
});
// Stateless mode: no server-initiated streams or sessions to delete.
const methodNotAllowed = (req, res) => {
res.status(405).set('Allow', 'POST').json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed. Use POST.' },
id: null,
});
};
app.get('/mcp', methodNotAllowed);
app.delete('/mcp', methodNotAllowed);
// Follow the live endpoint catalog. Startup waits for the first fetch at
// most ~3s; on failure the bundled tools.js is served and retried later.
await startLiveCatalog();
app.listen(PORT, () => {
console.log(`✅ MCP server running on port ${PORT}`);
console.log(`🔌 Hosted MCP endpoint: http://localhost:${PORT}/mcp`);
console.log(`📡 Legacy proxy endpoint: http://localhost:${PORT}/proxy`);
console.log(`🛠️ Available tools: http://localhost:${PORT}/tools`);
console.log(`❤️ Health check: http://localhost:${PORT}/health`);
});