@dalcontak/blogger-mcp-server 1.0.1 → 1.0.2

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