@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
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for BloggerService
|
|
3
|
+
*
|
|
4
|
+
* We mock 'googleapis' and './config' to test the service logic
|
|
5
|
+
* without making real API calls.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Mock googleapis before importing anything
|
|
9
|
+
const mockBlogsListByUser = jest.fn();
|
|
10
|
+
const mockBlogsGet = jest.fn();
|
|
11
|
+
const mockBlogsGetByUrl = jest.fn();
|
|
12
|
+
const mockPostsList = jest.fn();
|
|
13
|
+
const mockPostsSearch = jest.fn();
|
|
14
|
+
const mockPostsGet = jest.fn();
|
|
15
|
+
const mockPostsInsert = jest.fn();
|
|
16
|
+
const mockPostsUpdate = jest.fn();
|
|
17
|
+
const mockPostsDelete = jest.fn();
|
|
18
|
+
const mockSetCredentials = jest.fn();
|
|
19
|
+
|
|
20
|
+
jest.mock('googleapis', () => ({
|
|
21
|
+
google: {
|
|
22
|
+
auth: {
|
|
23
|
+
OAuth2: jest.fn(() => ({
|
|
24
|
+
setCredentials: mockSetCredentials
|
|
25
|
+
}))
|
|
26
|
+
},
|
|
27
|
+
blogger: jest.fn(() => ({
|
|
28
|
+
blogs: {
|
|
29
|
+
listByUser: mockBlogsListByUser,
|
|
30
|
+
get: mockBlogsGet,
|
|
31
|
+
getByUrl: mockBlogsGetByUrl
|
|
32
|
+
},
|
|
33
|
+
posts: {
|
|
34
|
+
list: mockPostsList,
|
|
35
|
+
search: mockPostsSearch,
|
|
36
|
+
get: mockPostsGet,
|
|
37
|
+
insert: mockPostsInsert,
|
|
38
|
+
update: mockPostsUpdate,
|
|
39
|
+
delete: mockPostsDelete
|
|
40
|
+
}
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Mock config type — allows string or undefined for optional fields
|
|
46
|
+
interface MockConfig {
|
|
47
|
+
blogger: { apiKey: string | undefined; maxResults: number; timeout: number };
|
|
48
|
+
oauth2: { clientId: string | undefined; clientSecret: string | undefined; refreshToken: string | undefined };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const defaultMockConfig: MockConfig = {
|
|
52
|
+
blogger: { apiKey: 'test-api-key', maxResults: 10, timeout: 30000 },
|
|
53
|
+
oauth2: { clientId: undefined, clientSecret: undefined, refreshToken: undefined },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let mockConfig: MockConfig = { ...defaultMockConfig };
|
|
57
|
+
|
|
58
|
+
jest.mock('./config', () => ({
|
|
59
|
+
get config() {
|
|
60
|
+
return mockConfig;
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
import { BloggerService } from './bloggerService';
|
|
65
|
+
import { google } from 'googleapis';
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
jest.clearAllMocks();
|
|
69
|
+
// Suppress console.log/error in tests
|
|
70
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
// Reset to default config
|
|
73
|
+
mockConfig = { ...defaultMockConfig };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
jest.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ─── Constructor / Auth ─────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('BloggerService constructor', () => {
|
|
83
|
+
|
|
84
|
+
it('should initialize with API key when only apiKey is set', () => {
|
|
85
|
+
mockConfig = {
|
|
86
|
+
blogger: { apiKey: 'my-key', maxResults: 10, timeout: 30000 },
|
|
87
|
+
oauth2: { clientId: undefined, clientSecret: undefined, refreshToken: undefined },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const service = new BloggerService();
|
|
91
|
+
expect(google.blogger).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({ version: 'v3', auth: 'my-key' })
|
|
93
|
+
);
|
|
94
|
+
// isOAuth2 is private — we test it indirectly via requireOAuth2 behavior
|
|
95
|
+
expect(() => (service as any).requireOAuth2('test')).toThrow(/requires OAuth2/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should initialize with OAuth2 when all OAuth2 vars are set', () => {
|
|
99
|
+
mockConfig = {
|
|
100
|
+
blogger: { apiKey: 'my-key', maxResults: 10, timeout: 30000 },
|
|
101
|
+
oauth2: { clientId: 'cid', clientSecret: 'csec', refreshToken: 'rtok' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const service = new BloggerService();
|
|
105
|
+
expect(google.auth.OAuth2).toHaveBeenCalledWith('cid', 'csec');
|
|
106
|
+
expect(mockSetCredentials).toHaveBeenCalledWith({ refresh_token: 'rtok' });
|
|
107
|
+
// Should NOT throw on OAuth2-required operations
|
|
108
|
+
expect(() => (service as any).requireOAuth2('test')).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should throw when no authentication is configured', () => {
|
|
112
|
+
mockConfig = {
|
|
113
|
+
blogger: { apiKey: undefined, maxResults: 10, timeout: 30000 },
|
|
114
|
+
oauth2: { clientId: undefined, clientSecret: undefined, refreshToken: undefined },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(() => new BloggerService()).toThrow(/No authentication configured/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should fall back to API key when OAuth2 is partially configured', () => {
|
|
121
|
+
mockConfig = {
|
|
122
|
+
blogger: { apiKey: 'my-key', maxResults: 10, timeout: 30000 },
|
|
123
|
+
oauth2: { clientId: 'cid', clientSecret: undefined, refreshToken: undefined },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const service = new BloggerService();
|
|
127
|
+
// Should have used API key path, not OAuth2
|
|
128
|
+
expect(google.auth.OAuth2).not.toHaveBeenCalled();
|
|
129
|
+
expect(google.blogger).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({ auth: 'my-key' })
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─── requireOAuth2 guard ────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe('requireOAuth2 guard', () => {
|
|
138
|
+
let apiKeyService: BloggerService;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
mockConfig = {
|
|
142
|
+
blogger: { apiKey: 'key', maxResults: 10, timeout: 30000 },
|
|
143
|
+
oauth2: { clientId: undefined, clientSecret: undefined, refreshToken: undefined },
|
|
144
|
+
};
|
|
145
|
+
apiKeyService = new BloggerService();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should reject listBlogs in API key mode', async () => {
|
|
149
|
+
await expect(apiKeyService.listBlogs()).rejects.toThrow(/requires OAuth2/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should reject createPost in API key mode', async () => {
|
|
153
|
+
await expect(apiKeyService.createPost('blog1', { title: 'T' })).rejects.toThrow(/requires OAuth2/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should reject updatePost in API key mode', async () => {
|
|
157
|
+
await expect(apiKeyService.updatePost('blog1', 'post1', { title: 'T' })).rejects.toThrow(/requires OAuth2/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should reject deletePost in API key mode', async () => {
|
|
161
|
+
await expect(apiKeyService.deletePost('blog1', 'post1')).rejects.toThrow(/requires OAuth2/);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── Read operations (API key mode) ────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('read operations', () => {
|
|
168
|
+
let service: BloggerService;
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
mockConfig = {
|
|
172
|
+
blogger: { apiKey: 'key', maxResults: 5, timeout: 30000 },
|
|
173
|
+
oauth2: { clientId: undefined, clientSecret: undefined, refreshToken: undefined },
|
|
174
|
+
};
|
|
175
|
+
service = new BloggerService();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('getBlog', () => {
|
|
179
|
+
it('should call blogs.get with the blogId and return data', async () => {
|
|
180
|
+
const mockBlog = { id: '123', name: 'Test Blog' };
|
|
181
|
+
mockBlogsGet.mockResolvedValue({ data: mockBlog });
|
|
182
|
+
|
|
183
|
+
const result = await service.getBlog('123');
|
|
184
|
+
expect(mockBlogsGet).toHaveBeenCalledWith({ blogId: '123' });
|
|
185
|
+
expect(result).toEqual(mockBlog);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should propagate API errors', async () => {
|
|
189
|
+
mockBlogsGet.mockRejectedValue(new Error('API error'));
|
|
190
|
+
await expect(service.getBlog('bad')).rejects.toThrow('API error');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('getBlogByUrl', () => {
|
|
195
|
+
it('should call blogs.getByUrl with the URL and return data', async () => {
|
|
196
|
+
const mockBlog = { id: '123', name: 'Test Blog', url: 'https://test.blogspot.com' };
|
|
197
|
+
mockBlogsGetByUrl.mockResolvedValue({ data: mockBlog });
|
|
198
|
+
|
|
199
|
+
const result = await service.getBlogByUrl('https://test.blogspot.com');
|
|
200
|
+
expect(mockBlogsGetByUrl).toHaveBeenCalledWith({ url: 'https://test.blogspot.com' });
|
|
201
|
+
expect(result).toEqual(mockBlog);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should propagate API errors', async () => {
|
|
205
|
+
mockBlogsGetByUrl.mockRejectedValue(new Error('Not found'));
|
|
206
|
+
await expect(service.getBlogByUrl('https://bad-url.com')).rejects.toThrow('Not found');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('listPosts', () => {
|
|
211
|
+
it('should call posts.list with blogId and config default maxResults', async () => {
|
|
212
|
+
const mockPosts = { items: [{ id: '1', title: 'Post 1' }] };
|
|
213
|
+
mockPostsList.mockResolvedValue({ data: mockPosts });
|
|
214
|
+
|
|
215
|
+
const result = await service.listPosts('blog1');
|
|
216
|
+
expect(mockPostsList).toHaveBeenCalledWith({ blogId: 'blog1', maxResults: 5 });
|
|
217
|
+
expect(result).toEqual(mockPosts);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should use explicit maxResults when provided', async () => {
|
|
221
|
+
mockPostsList.mockResolvedValue({ data: { items: [] } });
|
|
222
|
+
|
|
223
|
+
await service.listPosts('blog1', 20);
|
|
224
|
+
expect(mockPostsList).toHaveBeenCalledWith({ blogId: 'blog1', maxResults: 20 });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('searchPosts', () => {
|
|
229
|
+
it('should call posts.search with the query', async () => {
|
|
230
|
+
const items = [{ id: '1' }, { id: '2' }, { id: '3' }];
|
|
231
|
+
mockPostsSearch.mockResolvedValue({ data: { kind: 'blogger#postList', items } });
|
|
232
|
+
|
|
233
|
+
const result = await service.searchPosts('blog1', 'typescript');
|
|
234
|
+
expect(mockPostsSearch).toHaveBeenCalledWith({
|
|
235
|
+
blogId: 'blog1', q: 'typescript', fetchBodies: true
|
|
236
|
+
});
|
|
237
|
+
expect(result.items).toHaveLength(3);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should truncate results to maxResults', async () => {
|
|
241
|
+
const items = Array.from({ length: 20 }, (_, i) => ({ id: String(i) }));
|
|
242
|
+
mockPostsSearch.mockResolvedValue({ data: { kind: 'blogger#postList', items } });
|
|
243
|
+
|
|
244
|
+
// config maxResults is 5
|
|
245
|
+
const result = await service.searchPosts('blog1', 'query');
|
|
246
|
+
expect(result.items).toHaveLength(5);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should use explicit maxResults over config default', async () => {
|
|
250
|
+
const items = Array.from({ length: 20 }, (_, i) => ({ id: String(i) }));
|
|
251
|
+
mockPostsSearch.mockResolvedValue({ data: { kind: 'blogger#postList', items } });
|
|
252
|
+
|
|
253
|
+
const result = await service.searchPosts('blog1', 'query', 3);
|
|
254
|
+
expect(result.items).toHaveLength(3);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should handle empty results', async () => {
|
|
258
|
+
mockPostsSearch.mockResolvedValue({ data: { kind: 'blogger#postList' } });
|
|
259
|
+
|
|
260
|
+
const result = await service.searchPosts('blog1', 'nothing');
|
|
261
|
+
expect(result.items).toHaveLength(0);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('getPost', () => {
|
|
266
|
+
it('should call posts.get with blogId and postId', async () => {
|
|
267
|
+
const mockPost = { id: 'p1', title: 'My Post' };
|
|
268
|
+
mockPostsGet.mockResolvedValue({ data: mockPost });
|
|
269
|
+
|
|
270
|
+
const result = await service.getPost('blog1', 'p1');
|
|
271
|
+
expect(mockPostsGet).toHaveBeenCalledWith({ blogId: 'blog1', postId: 'p1' });
|
|
272
|
+
expect(result).toEqual(mockPost);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('listLabels', () => {
|
|
277
|
+
it('should extract unique labels from posts', async () => {
|
|
278
|
+
mockPostsList.mockResolvedValue({
|
|
279
|
+
data: {
|
|
280
|
+
items: [
|
|
281
|
+
{ id: '1', labels: ['tech', 'js'] },
|
|
282
|
+
{ id: '2', labels: ['tech', 'python'] },
|
|
283
|
+
{ id: '3', labels: null },
|
|
284
|
+
{ id: '4' } // no labels field
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const result = await service.listLabels('blog1');
|
|
290
|
+
expect(result.kind).toBe('blogger#labelList');
|
|
291
|
+
const names = result.items!.map(l => l.name).sort();
|
|
292
|
+
expect(names).toEqual(['js', 'python', 'tech']);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should return empty list when no posts exist', async () => {
|
|
296
|
+
mockPostsList.mockResolvedValue({ data: { items: [] } });
|
|
297
|
+
|
|
298
|
+
const result = await service.listLabels('blog1');
|
|
299
|
+
expect(result.items).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should return empty list when posts have no labels', async () => {
|
|
303
|
+
mockPostsList.mockResolvedValue({
|
|
304
|
+
data: { items: [{ id: '1' }, { id: '2' }] }
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const result = await service.listLabels('blog1');
|
|
308
|
+
expect(result.items).toEqual([]);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('getLabel', () => {
|
|
313
|
+
it('should return the label when found', async () => {
|
|
314
|
+
mockPostsList.mockResolvedValue({
|
|
315
|
+
data: { items: [{ id: '1', labels: ['tech', 'js'] }] }
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const result = await service.getLabel('blog1', 'tech');
|
|
319
|
+
expect(result).toEqual({ name: 'tech' });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should throw when label is not found', async () => {
|
|
323
|
+
mockPostsList.mockResolvedValue({
|
|
324
|
+
data: { items: [{ id: '1', labels: ['tech'] }] }
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await expect(service.getLabel('blog1', 'nonexistent')).rejects.toThrow(/not found/);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ─── Write operations (OAuth2 mode) ────────────────────────
|
|
333
|
+
|
|
334
|
+
describe('write operations (OAuth2)', () => {
|
|
335
|
+
let service: BloggerService;
|
|
336
|
+
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
mockConfig = {
|
|
339
|
+
blogger: { apiKey: 'key', maxResults: 10, timeout: 30000 },
|
|
340
|
+
oauth2: { clientId: 'cid', clientSecret: 'csec', refreshToken: 'rtok' },
|
|
341
|
+
};
|
|
342
|
+
service = new BloggerService();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('listBlogs', () => {
|
|
346
|
+
it('should call blogs.listByUser with self', async () => {
|
|
347
|
+
const mockData = { items: [{ id: 'b1' }] };
|
|
348
|
+
mockBlogsListByUser.mockResolvedValue({ data: mockData });
|
|
349
|
+
|
|
350
|
+
const result = await service.listBlogs();
|
|
351
|
+
expect(mockBlogsListByUser).toHaveBeenCalledWith({ userId: 'self' });
|
|
352
|
+
expect(result).toEqual(mockData);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('createPost', () => {
|
|
357
|
+
it('should call posts.insert with correct params', async () => {
|
|
358
|
+
const newPost = { title: 'New', content: '<p>Hello</p>', labels: ['test'] };
|
|
359
|
+
mockPostsInsert.mockResolvedValue({ data: { id: 'p1', ...newPost } });
|
|
360
|
+
|
|
361
|
+
const result = await service.createPost('blog1', newPost);
|
|
362
|
+
expect(mockPostsInsert).toHaveBeenCalledWith({
|
|
363
|
+
blogId: 'blog1',
|
|
364
|
+
requestBody: newPost
|
|
365
|
+
});
|
|
366
|
+
expect(result.title).toBe('New');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('updatePost', () => {
|
|
371
|
+
it('should call posts.update with correct params', async () => {
|
|
372
|
+
const updates = { title: 'Updated Title' };
|
|
373
|
+
mockPostsUpdate.mockResolvedValue({ data: { id: 'p1', title: 'Updated Title' } });
|
|
374
|
+
|
|
375
|
+
const result = await service.updatePost('blog1', 'p1', updates);
|
|
376
|
+
expect(mockPostsUpdate).toHaveBeenCalledWith({
|
|
377
|
+
blogId: 'blog1',
|
|
378
|
+
postId: 'p1',
|
|
379
|
+
requestBody: expect.objectContaining({ title: 'Updated Title' })
|
|
380
|
+
});
|
|
381
|
+
expect(result.title).toBe('Updated Title');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('deletePost', () => {
|
|
386
|
+
it('should call posts.delete with correct params', async () => {
|
|
387
|
+
mockPostsDelete.mockResolvedValue({});
|
|
388
|
+
|
|
389
|
+
await service.deletePost('blog1', 'p1');
|
|
390
|
+
expect(mockPostsDelete).toHaveBeenCalledWith({ blogId: 'blog1', postId: 'p1' });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should propagate API errors', async () => {
|
|
394
|
+
mockPostsDelete.mockRejectedValue(new Error('forbidden'));
|
|
395
|
+
await expect(service.deletePost('blog1', 'p1')).rejects.toThrow('forbidden');
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|