@dalcontak/blogger-mcp-server 1.0.1 → 1.0.3

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/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ #!/usr/bin/env node
2
+
1
3
  import { config } from './config';
2
4
  import { BloggerService } from './bloggerService';
3
5
  import { initMCPServer, createToolDefinitions } from './server';
@@ -6,17 +8,13 @@ import { Server as HttpServer } from 'http';
6
8
  import { ServerMode, ServerStatus, ClientConnection, ServerStats } from './types';
7
9
  import { WebUIManager } from './ui-manager';
8
10
 
9
- /**
10
- * Main entry point for the Blogger MCP server
11
- */
12
11
  async function main() {
13
12
  try {
14
13
  console.log('Starting Blogger MCP server...');
15
-
16
- // Verify that at least one authentication method is configured
14
+
17
15
  const hasOAuth2 = !!(config.oauth2.clientId && config.oauth2.clientSecret && config.oauth2.refreshToken);
18
16
  const hasApiKey = !!config.blogger.apiKey;
19
-
17
+
20
18
  if (!hasOAuth2 && !hasApiKey) {
21
19
  console.error(
22
20
  'ERROR: No authentication configured.\n' +
@@ -25,50 +23,37 @@ async function main() {
25
23
  );
26
24
  process.exit(1);
27
25
  }
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
26
+
27
+ console.log(`Authentication mode: ${hasOAuth2 ? 'OAuth2 (full access)' : 'API Key (read-only)'}`);
28
+
36
29
  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 }
30
+
31
+ const serverMode: ServerMode = config.mode === 'http'
32
+ ? { type: 'http' as const, host: config.http.host, port: config.http.port }
41
33
  : { type: 'stdio' as const };
42
-
34
+
43
35
  const serverConfig = {
44
36
  mode: serverMode,
45
37
  blogger: config.blogger,
46
38
  oauth2: config.oauth2,
47
39
  logging: config.logging
48
40
  };
49
-
50
- // Initialize the MCP server with all tools
41
+
51
42
  const server = initMCPServer(bloggerService, serverConfig);
52
-
53
- // Get tool definitions for direct access in HTTP mode and stats
43
+
54
44
  const toolDefinitions = createToolDefinitions(bloggerService);
55
45
  const toolMap = new Map(toolDefinitions.map(t => [t.name, t]));
56
46
  const serverTools = toolDefinitions.map(t => t.name);
57
47
 
58
- // Initialize the Web UI only if UI_PORT is set
59
48
  let uiManager: WebUIManager | undefined;
60
49
  let uiPort: number | undefined;
61
50
 
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
- }
51
+ if (config.ui.port > 0 && config.ui.port < 65536) {
52
+ uiManager = new WebUIManager();
53
+ uiPort = config.ui.port;
54
+ await uiManager.start(uiPort);
69
55
  }
70
56
 
71
- // Initialize server statistics and status
72
57
  let serverStatus: ServerStatus = {
73
58
  running: true,
74
59
  mode: serverMode.type,
@@ -77,28 +62,89 @@ async function main() {
77
62
  tools: serverTools
78
63
  };
79
64
 
80
- const serverStats: ServerStats = {
65
+ const connections: Record<string, ClientConnection> = {};
66
+ const stats = {
81
67
  totalRequests: 0,
82
68
  successfulRequests: 0,
83
- failedRequests: 0,
84
- averageResponseTime: 0,
85
- toolUsage: serverTools.reduce((acc, tool) => {
69
+ totalResponseTime: 0,
70
+ toolUsage: serverTools.reduce<Record<string, number>>((acc, tool) => {
86
71
  acc[tool] = 0;
87
72
  return acc;
88
- }, {} as Record<string, number>)
73
+ }, {})
89
74
  };
90
75
 
76
+ function updateStats(tool: string, success = true, duration = 0) {
77
+ stats.totalRequests++;
78
+ if (success) {
79
+ stats.successfulRequests++;
80
+ stats.totalResponseTime += duration;
81
+ }
82
+
83
+ if (stats.toolUsage[tool] !== undefined) {
84
+ stats.toolUsage[tool]++;
85
+ }
86
+
87
+ const updatedStats: ServerStats = {
88
+ totalRequests: stats.totalRequests,
89
+ successfulRequests: stats.successfulRequests,
90
+ failedRequests: stats.totalRequests - stats.successfulRequests,
91
+ averageResponseTime: stats.successfulRequests > 0
92
+ ? Math.round(stats.totalResponseTime / stats.successfulRequests)
93
+ : 0,
94
+ toolUsage: stats.toolUsage
95
+ };
96
+
97
+ uiManager?.updateStats(updatedStats);
98
+ }
99
+
100
+ function updateConnections(clientId: string, clientIp?: string) {
101
+ const now = new Date();
102
+
103
+ if (!connections[clientId]) {
104
+ connections[clientId] = {
105
+ id: clientId,
106
+ ip: clientIp,
107
+ connectedAt: now,
108
+ lastActivity: now,
109
+ requestCount: 1
110
+ };
111
+ } else {
112
+ connections[clientId].lastActivity = now;
113
+ connections[clientId].requestCount++;
114
+ }
115
+
116
+ const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
117
+ Object.keys(connections).forEach(id => {
118
+ if (connections[id].lastActivity < fiveMinutesAgo) {
119
+ delete connections[id];
120
+ }
121
+ });
122
+
123
+ uiManager?.updateConnections(Object.values(connections));
124
+
125
+ serverStatus = {
126
+ ...serverStatus,
127
+ connections: Object.keys(connections).length
128
+ };
129
+
130
+ uiManager?.updateStatus(serverStatus);
131
+ }
132
+
91
133
  if (uiManager) {
134
+ const initialStats: ServerStats = {
135
+ totalRequests: 0,
136
+ successfulRequests: 0,
137
+ failedRequests: 0,
138
+ averageResponseTime: 0,
139
+ toolUsage: stats.toolUsage
140
+ };
92
141
  uiManager.updateStatus(serverStatus);
93
- uiManager.updateStats(serverStats);
142
+ uiManager.updateStats(initialStats);
94
143
  }
95
-
96
- // Configure the appropriate transport based on the mode
144
+
97
145
  let httpServer: HttpServer | undefined;
98
146
 
99
147
  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
148
  const httpMode = serverMode;
103
149
  httpServer = new HttpServer((req, res) => {
104
150
  if (req.method === 'OPTIONS') {
@@ -110,112 +156,74 @@ async function main() {
110
156
  res.end();
111
157
  return;
112
158
  }
113
-
159
+
114
160
  if (req.method !== 'POST') {
115
161
  res.writeHead(405, { 'Content-Type': 'application/json' });
116
162
  res.end(JSON.stringify({ error: 'Method not allowed' }));
117
163
  return;
118
164
  }
119
-
165
+
120
166
  let body = '';
121
167
  let bodySize = 0;
122
- const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
168
+ const MAX_BODY_SIZE = 1024 * 1024;
123
169
 
124
170
  req.on('data', chunk => {
125
171
  bodySize += chunk.length;
126
172
  if (bodySize > MAX_BODY_SIZE) {
127
173
  res.writeHead(413, { 'Content-Type': 'application/json' });
128
174
  res.end(JSON.stringify({ error: 'Request entity too large' }));
129
- req.destroy(); // Stop receiving data
175
+ req.destroy();
130
176
  return;
131
177
  }
132
178
  body += chunk.toString();
133
179
  });
134
-
180
+
135
181
  req.on('end', async () => {
136
182
  if (req.destroyed) return;
137
183
 
138
184
  try {
139
185
  const request = JSON.parse(body);
140
186
  const { tool, params } = request;
141
-
142
- // Add client connection
187
+
143
188
  const clientIp = req.socket.remoteAddress || 'unknown';
144
189
  updateConnections(req.socket.remotePort?.toString() || 'client', clientIp);
145
-
146
- // Call the appropriate tool
190
+
147
191
  try {
148
192
  const startTime = Date.now();
149
-
150
- const toolDef = toolMap.get(tool);
151
193
 
194
+ const toolDef = toolMap.get(tool);
152
195
  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}`);
196
+ throw new Error(`Unknown tool: ${tool}`);
162
197
  }
163
198
 
164
- // Execute tool handler
199
+ const validatedParams = toolDef.args.parse(params || {});
165
200
  const result = await toolDef.handler(validatedParams);
166
-
201
+
167
202
  const duration = Date.now() - startTime;
168
-
169
- // Update success statistics
170
203
  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, {
204
+
205
+ res.writeHead(200, {
181
206
  'Content-Type': 'application/json',
182
207
  'Access-Control-Allow-Origin': '*'
183
208
  });
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
+ const textContent = result.content[0]?.text;
211
+ if (textContent) {
212
+ try {
213
+ const parsedContent = JSON.parse(textContent);
214
+ res.end(JSON.stringify(parsedContent));
215
+ } catch {
216
+ res.end(JSON.stringify(result));
217
+ }
218
+ } else {
219
+ res.end(JSON.stringify(result));
209
220
  }
210
221
 
211
222
  } catch (error) {
212
- // Update failure statistics
213
223
  updateStats(tool, false);
214
-
224
+
215
225
  res.writeHead(400, { 'Content-Type': 'application/json' });
216
- res.end(JSON.stringify({
217
- error: `Error executing tool: ${error}`
218
- }));
226
+ res.end(JSON.stringify({ error: `Error executing tool: ${error}` }));
219
227
  }
220
228
  } catch (error) {
221
229
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -223,7 +231,7 @@ async function main() {
223
231
  }
224
232
  });
225
233
  });
226
-
234
+
227
235
  httpServer.listen(httpMode.port, httpMode.host, () => {
228
236
  console.log(`Blogger MCP server started in HTTP mode`);
229
237
  console.log(`Listening on ${httpMode.host}:${httpMode.port}`);
@@ -232,107 +240,23 @@ async function main() {
232
240
  }
233
241
  });
234
242
  } else {
235
- // For stdio mode, we use the official MCP SDK transport
236
243
  const transport = new StdioServerTransport();
237
244
  await server.connect(transport);
238
- console.log(`Blogger MCP server started in stdio mode`);
245
+ console.log('Blogger MCP server started in stdio mode');
239
246
  if (uiPort) {
240
247
  console.log(`Web UI available at http://localhost:${uiPort}`);
241
248
  }
242
249
  }
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
250
 
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
251
  const shutdown = async () => {
325
252
  console.log('Shutting down...');
326
253
  serverStatus = { ...serverStatus, running: false };
327
- if (uiManager) {
328
- uiManager.updateStatus(serverStatus);
329
- }
254
+ uiManager?.updateStatus(serverStatus);
330
255
 
331
256
  if (httpServer) {
332
257
  httpServer.close();
333
258
  }
334
259
 
335
- // Allow time for cleanup if needed
336
260
  setTimeout(() => process.exit(0), 1000);
337
261
  };
338
262
 
@@ -345,5 +269,4 @@ async function main() {
345
269
  }
346
270
  }
347
271
 
348
- // Run main function
349
272
  main();
@@ -0,0 +1,128 @@
1
+ import { createToolDefinitions, initMCPServer } from './server';
2
+ import { ToolResult } from './types';
3
+
4
+ jest.mock('./bloggerService', () => ({
5
+ BloggerService: jest.fn().mockImplementation(() => ({
6
+ listBlogs: jest.fn().mockResolvedValue({ items: [] }),
7
+ getBlog: jest.fn().mockResolvedValue({ id: 'b1' }),
8
+ getBlogByUrl: jest.fn().mockResolvedValue({ id: 'b1' }),
9
+ listPosts: jest.fn().mockResolvedValue({ items: [] }),
10
+ searchPosts: jest.fn().mockResolvedValue({ items: [] }),
11
+ getPost: jest.fn().mockResolvedValue({ id: 'p1' }),
12
+ createPost: jest.fn().mockResolvedValue({ id: 'p1' }),
13
+ updatePost: jest.fn().mockResolvedValue({ id: 'p1' }),
14
+ deletePost: jest.fn().mockResolvedValue(undefined),
15
+ listLabels: jest.fn().mockResolvedValue({ items: [] }),
16
+ getLabel: jest.fn().mockResolvedValue({ name: 'test' }),
17
+ }))
18
+ }));
19
+
20
+ jest.mock('./config', () => ({
21
+ config: {
22
+ mode: 'stdio',
23
+ http: { host: '0.0.0.0', port: 3000 },
24
+ blogger: { apiKey: 'test-key', maxResults: 10, timeout: 30000 },
25
+ oauth2: { clientId: 'cid', clientSecret: 'csec', refreshToken: 'rtok' },
26
+ logging: { level: 'info' },
27
+ ui: { port: 0 }
28
+ }
29
+ }));
30
+
31
+ import { BloggerService } from './bloggerService';
32
+
33
+ beforeEach(() => {
34
+ jest.spyOn(console, 'log').mockImplementation(() => {});
35
+ jest.spyOn(console, 'error').mockImplementation(() => {});
36
+ });
37
+
38
+ afterEach(() => {
39
+ jest.restoreAllMocks();
40
+ });
41
+
42
+ describe('createToolDefinitions', () => {
43
+ const service = new BloggerService();
44
+ const tools = createToolDefinitions(service);
45
+
46
+ it('should define 11 tools', () => {
47
+ expect(tools).toHaveLength(11);
48
+ });
49
+
50
+ it('should have correct tool names', () => {
51
+ const names = tools.map(t => t.name);
52
+ expect(names).toEqual([
53
+ 'list_blogs', 'get_blog', 'get_blog_by_url',
54
+ 'list_posts', 'search_posts', 'get_post',
55
+ 'create_post', 'update_post', 'delete_post',
56
+ 'list_labels', 'get_label'
57
+ ]);
58
+ });
59
+
60
+ it('should not include create_blog', () => {
61
+ const names = tools.map(t => t.name);
62
+ expect(names).not.toContain('create_blog');
63
+ });
64
+
65
+ it('each tool should have a non-empty description', () => {
66
+ for (const tool of tools) {
67
+ expect(tool.description.length).toBeGreaterThan(0);
68
+ }
69
+ });
70
+
71
+ it('each tool should have a ZodObject args schema', () => {
72
+ for (const tool of tools) {
73
+ expect(tool.args).toBeDefined();
74
+ expect(tool.args.shape).toBeDefined();
75
+ }
76
+ });
77
+
78
+ it('each tool should have a handler function', () => {
79
+ for (const tool of tools) {
80
+ expect(typeof tool.handler).toBe('function');
81
+ }
82
+ });
83
+ });
84
+
85
+ describe('createToolHandler error wrapping', () => {
86
+ const service = new BloggerService();
87
+ const tools = createToolDefinitions(service);
88
+
89
+ it('should return isError=true when handler throws', async () => {
90
+ const getPostTool = tools.find(t => t.name === 'get_post')!;
91
+ const mockFn = service.getPost as jest.Mock;
92
+ mockFn.mockRejectedValueOnce(new Error('API failure'));
93
+
94
+ const result: ToolResult = await getPostTool.handler(
95
+ { blogId: 'b1', postId: 'p1' }
96
+ );
97
+
98
+ expect(result.isError).toBe(true);
99
+ expect(result.content[0].text).toContain('API failure');
100
+ });
101
+
102
+ it('should return JSON content on success', async () => {
103
+ const getBlogTool = tools.find(t => t.name === 'get_blog')!;
104
+ const mockFn = service.getBlog as jest.Mock;
105
+ mockFn.mockResolvedValueOnce({ id: 'b1', name: 'Test Blog' });
106
+
107
+ const result: ToolResult = await getBlogTool.handler({ blogId: 'b1' });
108
+
109
+ expect(result.isError).toBeUndefined();
110
+ const parsed = JSON.parse(result.content[0].text);
111
+ expect(parsed.blog.id).toBe('b1');
112
+ });
113
+ });
114
+
115
+ describe('initMCPServer', () => {
116
+ it('should return an MCP server instance', () => {
117
+ const service = new BloggerService();
118
+ const serverConfig = {
119
+ mode: { type: 'stdio' as const },
120
+ blogger: { apiKey: 'test', maxResults: 10, timeout: 30000 },
121
+ oauth2: {},
122
+ logging: { level: 'info' }
123
+ };
124
+
125
+ const server = initMCPServer(service, serverConfig);
126
+ expect(server).toBeDefined();
127
+ });
128
+ });