@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/.github/workflows/publish.yml +30 -1
- 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.d.ts +1 -0
- package/dist/index.js +81 -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 +4 -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 +117 -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
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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 (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
65
|
+
const connections: Record<string, ClientConnection> = {};
|
|
66
|
+
const stats = {
|
|
81
67
|
totalRequests: 0,
|
|
82
68
|
successfulRequests: 0,
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
}, {}
|
|
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(
|
|
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;
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|