@dalcontak/blogger-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +34 -0
- package/AGENTS.md +155 -0
- package/Dockerfile +64 -0
- package/README.md +169 -0
- package/RELEASE.md +125 -0
- package/dist/bloggerService.d.ts +121 -0
- package/dist/bloggerService.js +323 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +304 -0
- package/dist/mcp-sdk-mock.d.ts +57 -0
- package/dist/mcp-sdk-mock.js +227 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +448 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +2 -0
- package/dist/ui-manager.d.ts +22 -0
- package/dist/ui-manager.js +110 -0
- package/jest.config.js +7 -0
- package/package.json +43 -0
- package/public/index.html +201 -0
- package/public/main.js +271 -0
- package/public/styles.css +155 -0
- package/src/bloggerService.test.ts +398 -0
- package/src/bloggerService.ts +351 -0
- package/src/config.test.ts +121 -0
- package/src/config.ts +33 -0
- package/src/index.ts +349 -0
- package/src/server.ts +443 -0
- package/src/types.ts +113 -0
- package/src/ui-manager.ts +128 -0
- package/start-dev.sh +64 -0
- package/start-prod.sh +53 -0
- package/tsconfig.json +15 -0
- package/vercel.json +24 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { config } from './config';
|
|
2
|
+
import { BloggerService } from './bloggerService';
|
|
3
|
+
import { initMCPServer, createToolDefinitions } from './server';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { Server as HttpServer } from 'http';
|
|
6
|
+
import { ServerMode, ServerStatus, ClientConnection, ServerStats } from './types';
|
|
7
|
+
import { WebUIManager } from './ui-manager';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main entry point for the Blogger MCP server
|
|
11
|
+
*/
|
|
12
|
+
async function main() {
|
|
13
|
+
try {
|
|
14
|
+
console.log('Starting Blogger MCP server...');
|
|
15
|
+
|
|
16
|
+
// Verify that at least one authentication method is configured
|
|
17
|
+
const hasOAuth2 = !!(config.oauth2.clientId && config.oauth2.clientSecret && config.oauth2.refreshToken);
|
|
18
|
+
const hasApiKey = !!config.blogger.apiKey;
|
|
19
|
+
|
|
20
|
+
if (!hasOAuth2 && !hasApiKey) {
|
|
21
|
+
console.error(
|
|
22
|
+
'ERROR: No authentication configured.\n' +
|
|
23
|
+
'Set BLOGGER_API_KEY (read-only) or\n' +
|
|
24
|
+
'GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + GOOGLE_REFRESH_TOKEN (full access).'
|
|
25
|
+
);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (hasOAuth2) {
|
|
30
|
+
console.log('Authentication mode: OAuth2 (full access)');
|
|
31
|
+
} else {
|
|
32
|
+
console.log('Authentication mode: API Key (read-only)');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Initialize the Blogger service
|
|
36
|
+
const bloggerService = new BloggerService();
|
|
37
|
+
|
|
38
|
+
// Convert configuration to the format expected by the server
|
|
39
|
+
const serverMode: ServerMode = config.mode === 'http'
|
|
40
|
+
? { type: 'http' as const, host: config.http.host, port: config.http.port }
|
|
41
|
+
: { type: 'stdio' as const };
|
|
42
|
+
|
|
43
|
+
const serverConfig = {
|
|
44
|
+
mode: serverMode,
|
|
45
|
+
blogger: config.blogger,
|
|
46
|
+
oauth2: config.oauth2,
|
|
47
|
+
logging: config.logging
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Initialize the MCP server with all tools
|
|
51
|
+
const server = initMCPServer(bloggerService, serverConfig);
|
|
52
|
+
|
|
53
|
+
// Get tool definitions for direct access in HTTP mode and stats
|
|
54
|
+
const toolDefinitions = createToolDefinitions(bloggerService);
|
|
55
|
+
const toolMap = new Map(toolDefinitions.map(t => [t.name, t]));
|
|
56
|
+
const serverTools = toolDefinitions.map(t => t.name);
|
|
57
|
+
|
|
58
|
+
// Initialize the Web UI only if UI_PORT is set
|
|
59
|
+
let uiManager: WebUIManager | undefined;
|
|
60
|
+
let uiPort: number | undefined;
|
|
61
|
+
|
|
62
|
+
if (process.env.UI_PORT) {
|
|
63
|
+
const parsedPort = parseInt(process.env.UI_PORT);
|
|
64
|
+
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort < 65536) {
|
|
65
|
+
uiManager = new WebUIManager();
|
|
66
|
+
uiPort = parsedPort;
|
|
67
|
+
await uiManager.start(uiPort);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Initialize server statistics and status
|
|
72
|
+
let serverStatus: ServerStatus = {
|
|
73
|
+
running: true,
|
|
74
|
+
mode: serverMode.type,
|
|
75
|
+
startTime: new Date(),
|
|
76
|
+
connections: 0,
|
|
77
|
+
tools: serverTools
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const serverStats: ServerStats = {
|
|
81
|
+
totalRequests: 0,
|
|
82
|
+
successfulRequests: 0,
|
|
83
|
+
failedRequests: 0,
|
|
84
|
+
averageResponseTime: 0,
|
|
85
|
+
toolUsage: serverTools.reduce((acc, tool) => {
|
|
86
|
+
acc[tool] = 0;
|
|
87
|
+
return acc;
|
|
88
|
+
}, {} as Record<string, number>)
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (uiManager) {
|
|
92
|
+
uiManager.updateStatus(serverStatus);
|
|
93
|
+
uiManager.updateStats(serverStats);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Configure the appropriate transport based on the mode
|
|
97
|
+
let httpServer: HttpServer | undefined;
|
|
98
|
+
|
|
99
|
+
if (serverMode.type === 'http') {
|
|
100
|
+
// For HTTP mode, we use Node.js HTTP server directly
|
|
101
|
+
// since the official MCP SDK does not have an HttpServerTransport equivalent
|
|
102
|
+
const httpMode = serverMode;
|
|
103
|
+
httpServer = new HttpServer((req, res) => {
|
|
104
|
+
if (req.method === 'OPTIONS') {
|
|
105
|
+
res.writeHead(200, {
|
|
106
|
+
'Access-Control-Allow-Origin': '*',
|
|
107
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
108
|
+
'Access-Control-Allow-Headers': 'Content-Type'
|
|
109
|
+
});
|
|
110
|
+
res.end();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (req.method !== 'POST') {
|
|
115
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
116
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let body = '';
|
|
121
|
+
let bodySize = 0;
|
|
122
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
|
|
123
|
+
|
|
124
|
+
req.on('data', chunk => {
|
|
125
|
+
bodySize += chunk.length;
|
|
126
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
127
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
128
|
+
res.end(JSON.stringify({ error: 'Request entity too large' }));
|
|
129
|
+
req.destroy(); // Stop receiving data
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
body += chunk.toString();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
req.on('end', async () => {
|
|
136
|
+
if (req.destroyed) return;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const request = JSON.parse(body);
|
|
140
|
+
const { tool, params } = request;
|
|
141
|
+
|
|
142
|
+
// Add client connection
|
|
143
|
+
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
144
|
+
updateConnections(req.socket.remotePort?.toString() || 'client', clientIp);
|
|
145
|
+
|
|
146
|
+
// Call the appropriate tool
|
|
147
|
+
try {
|
|
148
|
+
const startTime = Date.now();
|
|
149
|
+
|
|
150
|
+
const toolDef = toolMap.get(tool);
|
|
151
|
+
|
|
152
|
+
if (!toolDef) {
|
|
153
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Validate parameters using Zod schema
|
|
157
|
+
let validatedParams;
|
|
158
|
+
try {
|
|
159
|
+
validatedParams = toolDef.args.parse(params || {});
|
|
160
|
+
} catch (validationError) {
|
|
161
|
+
throw new Error(`Invalid parameters: ${validationError}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Execute tool handler
|
|
165
|
+
const result = await toolDef.handler(validatedParams);
|
|
166
|
+
|
|
167
|
+
const duration = Date.now() - startTime;
|
|
168
|
+
|
|
169
|
+
// Update success statistics
|
|
170
|
+
updateStats(tool, true, duration);
|
|
171
|
+
|
|
172
|
+
// If the handler returned an isError: true, we might want to return 400 or just return the error object
|
|
173
|
+
// as per MCP protocol. Here we are in HTTP mode, let's just return 200 with the result object which contains the error message.
|
|
174
|
+
// But strictly speaking, if it's an error, we should probably update stats as failed?
|
|
175
|
+
// The handler catches exceptions and returns { isError: true, ... }.
|
|
176
|
+
// So if result.isError is true, we should count it as failed?
|
|
177
|
+
// The previous implementation counted catch block as failed.
|
|
178
|
+
// Let's stick to the previous logic: if handler throws, it's a failure. If handler returns result (even error result), it's success execution of the tool.
|
|
179
|
+
|
|
180
|
+
res.writeHead(200, {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'Access-Control-Allow-Origin': '*'
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// MCP Tools return { content: [...] }, but the previous HTTP implementation returned simplified objects like { blogs: [...] }.
|
|
186
|
+
// To maintain backward compatibility with the previous HTTP API (if any clients rely on it),
|
|
187
|
+
// we might need to transform the MCP result format back to the simplified format?
|
|
188
|
+
// The previous switch statement returned `result = { blogs }`.
|
|
189
|
+
// The tool handlers now return `{ content: [{ type: 'text', text: JSON.stringify({ blogs }) }] }`.
|
|
190
|
+
// We should probably parse the JSON text back if we want to return JSON.
|
|
191
|
+
// OR, we just return the MCP result directly.
|
|
192
|
+
// Given that this is an MCP server, clients should expect MCP format.
|
|
193
|
+
// HOWEVER, the `index.ts` HTTP implementation seemed to be a custom JSON API wrapper around the tools.
|
|
194
|
+
// Let's try to parse the response text if possible to match previous behavior,
|
|
195
|
+
// OR better: accept that the response format changes to MCP standard or keep it simple.
|
|
196
|
+
// The previous implementation was: `res.end(JSON.stringify(result))` where result was `{ blogs: ... }`.
|
|
197
|
+
// The tool handlers return `{ content: [{ text: "{\"blogs\":...}" }] }`.
|
|
198
|
+
|
|
199
|
+
// Let's unwrap it for HTTP mode to keep it friendly, or just return the text.
|
|
200
|
+
// If we want to return pure JSON like before:
|
|
201
|
+
try {
|
|
202
|
+
const textContent = result.content[0].text;
|
|
203
|
+
// If the text is JSON, parse it and return that.
|
|
204
|
+
const parsedContent = JSON.parse(textContent);
|
|
205
|
+
res.end(JSON.stringify(parsedContent));
|
|
206
|
+
} catch (e) {
|
|
207
|
+
// If not JSON, return as is wrapped
|
|
208
|
+
res.end(JSON.stringify(result));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// Update failure statistics
|
|
213
|
+
updateStats(tool, false);
|
|
214
|
+
|
|
215
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
216
|
+
res.end(JSON.stringify({
|
|
217
|
+
error: `Error executing tool: ${error}`
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
222
|
+
res.end(JSON.stringify({ error: `Parsing error: ${error}` }));
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
httpServer.listen(httpMode.port, httpMode.host, () => {
|
|
228
|
+
console.log(`Blogger MCP server started in HTTP mode`);
|
|
229
|
+
console.log(`Listening on ${httpMode.host}:${httpMode.port}`);
|
|
230
|
+
if (uiPort) {
|
|
231
|
+
console.log(`Web UI available at http://localhost:${uiPort}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
} else {
|
|
235
|
+
// For stdio mode, we use the official MCP SDK transport
|
|
236
|
+
const transport = new StdioServerTransport();
|
|
237
|
+
await server.connect(transport);
|
|
238
|
+
console.log(`Blogger MCP server started in stdio mode`);
|
|
239
|
+
if (uiPort) {
|
|
240
|
+
console.log(`Web UI available at http://localhost:${uiPort}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Functions to update statistics and connections
|
|
245
|
+
const connections: Record<string, ClientConnection> = {};
|
|
246
|
+
let stats = {
|
|
247
|
+
totalRequests: 0,
|
|
248
|
+
successfulRequests: 0,
|
|
249
|
+
failedRequests: 0,
|
|
250
|
+
totalResponseTime: 0,
|
|
251
|
+
toolUsage: serverTools.reduce((acc, tool) => {
|
|
252
|
+
acc[tool] = 0;
|
|
253
|
+
return acc;
|
|
254
|
+
}, {} as Record<string, number>)
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
function updateStats(tool: string, success = true, duration = 0) {
|
|
258
|
+
stats.totalRequests++;
|
|
259
|
+
if (success) {
|
|
260
|
+
stats.successfulRequests++;
|
|
261
|
+
stats.totalResponseTime += duration;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (stats.toolUsage[tool] !== undefined) {
|
|
265
|
+
stats.toolUsage[tool]++;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const updatedStats: ServerStats = {
|
|
269
|
+
totalRequests: stats.totalRequests,
|
|
270
|
+
successfulRequests: stats.successfulRequests,
|
|
271
|
+
failedRequests: stats.totalRequests - stats.successfulRequests,
|
|
272
|
+
averageResponseTime: stats.successfulRequests > 0
|
|
273
|
+
? Math.round(stats.totalResponseTime / stats.successfulRequests)
|
|
274
|
+
: 0,
|
|
275
|
+
toolUsage: stats.toolUsage
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (uiManager) {
|
|
279
|
+
uiManager.updateStats(updatedStats);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function updateConnections(clientId: string, clientIp?: string) {
|
|
284
|
+
const now = new Date();
|
|
285
|
+
|
|
286
|
+
if (!connections[clientId]) {
|
|
287
|
+
connections[clientId] = {
|
|
288
|
+
id: clientId,
|
|
289
|
+
ip: clientIp,
|
|
290
|
+
connectedAt: now,
|
|
291
|
+
lastActivity: now,
|
|
292
|
+
requestCount: 1
|
|
293
|
+
};
|
|
294
|
+
} else {
|
|
295
|
+
connections[clientId].lastActivity = now;
|
|
296
|
+
connections[clientId].requestCount++;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Clean up inactive connections (older than 5 minutes)
|
|
300
|
+
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
|
301
|
+
Object.keys(connections).forEach(id => {
|
|
302
|
+
if (connections[id].lastActivity < fiveMinutesAgo) {
|
|
303
|
+
delete connections[id];
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (uiManager) {
|
|
308
|
+
uiManager.updateConnections(Object.values(connections));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Update status with connection count
|
|
312
|
+
// FIX: Update the variable and then send it
|
|
313
|
+
serverStatus = {
|
|
314
|
+
...serverStatus,
|
|
315
|
+
connections: Object.keys(connections).length
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
if (uiManager) {
|
|
319
|
+
uiManager.updateStatus(serverStatus);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Graceful shutdown
|
|
324
|
+
const shutdown = async () => {
|
|
325
|
+
console.log('Shutting down...');
|
|
326
|
+
serverStatus = { ...serverStatus, running: false };
|
|
327
|
+
if (uiManager) {
|
|
328
|
+
uiManager.updateStatus(serverStatus);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (httpServer) {
|
|
332
|
+
httpServer.close();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Allow time for cleanup if needed
|
|
336
|
+
setTimeout(() => process.exit(0), 1000);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
process.on('SIGINT', shutdown);
|
|
340
|
+
process.on('SIGTERM', shutdown);
|
|
341
|
+
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error('Error starting Blogger MCP server:', error);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Run main function
|
|
349
|
+
main();
|