@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/README.md +40 -34
- package/RELEASE.md +64 -32
- package/dist/bloggerService.d.ts +7 -100
- package/dist/bloggerService.js +17 -146
- package/dist/config.d.ts +3 -0
- package/dist/config.js +12 -12
- package/dist/index.js +80 -154
- package/dist/server.d.ts +0 -11
- package/dist/server.js +59 -339
- package/dist/types.d.ts +15 -44
- package/dist/ui-manager.js +8 -16
- package/package.json +1 -1
- package/src/bloggerService.test.ts +5 -1
- package/src/bloggerService.ts +26 -161
- package/src/config.test.ts +34 -20
- package/src/config.ts +17 -16
- package/src/index.ts +115 -194
- package/src/server.test.ts +128 -0
- package/src/server.ts +63 -332
- package/src/types.ts +12 -60
- package/src/ui-manager.ts +17 -26
- package/Dockerfile +0 -64
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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 (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
63
|
+
const connections: Record<string, ClientConnection> = {};
|
|
64
|
+
const stats = {
|
|
81
65
|
totalRequests: 0,
|
|
82
66
|
successfulRequests: 0,
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
}, {}
|
|
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(
|
|
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;
|
|
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();
|
|
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
|
-
|
|
194
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
154
195
|
}
|
|
155
196
|
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|