@dalcontak/blogger-mcp-server 1.0.0 → 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.
@@ -1,33 +1,16 @@
1
1
  import { google, blogger_v3 } from 'googleapis';
2
- import { BloggerBlog, BloggerPost, BloggerLabel } from './types';
2
+ import { BloggerPost } from './types';
3
3
  import { config } from './config';
4
4
 
5
- /**
6
- * Custom types to compensate for Blogger API limitations
7
- */
8
5
  interface BloggerLabelList {
9
6
  kind?: string;
10
- items?: BloggerLabel[];
7
+ items?: Array<{ name: string }>;
11
8
  }
12
9
 
13
- /**
14
- * Google Blogger API interaction service
15
- *
16
- * Supports two authentication modes:
17
- * - OAuth2 (GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + GOOGLE_REFRESH_TOKEN):
18
- * full access (read + write). Required for listBlogs, createPost, updatePost, deletePost.
19
- * - API Key (BLOGGER_API_KEY): read-only access to public blogs.
20
- * Works for getBlog, listPosts, getPost, searchPosts, listLabels, getLabel.
21
- *
22
- * If both are configured, OAuth2 is used (it covers all operations).
23
- */
24
10
  export class BloggerService {
25
11
  private blogger: blogger_v3.Blogger;
26
12
  private readonly isOAuth2: boolean;
27
13
 
28
- /**
29
- * Initializes the Blogger service with OAuth2 or API key
30
- */
31
14
  constructor() {
32
15
  const { oauth2 } = config;
33
16
  const hasOAuth2 = !!(oauth2.clientId && oauth2.clientSecret && oauth2.refreshToken);
@@ -63,10 +46,6 @@ export class BloggerService {
63
46
  }
64
47
  }
65
48
 
66
- /**
67
- * Checks that OAuth2 authentication is available.
68
- * Throws an explicit error if the operation requires OAuth2 and we are in API key mode.
69
- */
70
49
  private requireOAuth2(operation: string): void {
71
50
  if (!this.isOAuth2) {
72
51
  throw new Error(
@@ -77,11 +56,6 @@ export class BloggerService {
77
56
  }
78
57
  }
79
58
 
80
- /**
81
- * Lists all blogs for the authenticated user.
82
- * Requires OAuth2 (blogs.listByUser with userId: 'self').
83
- * @returns Blog list
84
- */
85
59
  async listBlogs(): Promise<blogger_v3.Schema$BlogList> {
86
60
  this.requireOAuth2('list_blogs');
87
61
  try {
@@ -95,16 +69,9 @@ export class BloggerService {
95
69
  }
96
70
  }
97
71
 
98
- /**
99
- * Retrieves details of a specific blog
100
- * @param blogId ID of the blog to retrieve
101
- * @returns Blog details
102
- */
103
72
  async getBlog(blogId: string): Promise<blogger_v3.Schema$Blog> {
104
73
  try {
105
- const response = await this.blogger.blogs.get({
106
- blogId
107
- });
74
+ const response = await this.blogger.blogs.get({ blogId });
108
75
  return response.data;
109
76
  } catch (error) {
110
77
  console.error(`Error fetching blog ${blogId}:`, error);
@@ -112,16 +79,9 @@ export class BloggerService {
112
79
  }
113
80
  }
114
81
 
115
- /**
116
- * Retrieves a blog by its URL
117
- * @param url Blog URL
118
- * @returns Blog details
119
- */
120
82
  async getBlogByUrl(url: string): Promise<blogger_v3.Schema$Blog> {
121
83
  try {
122
- const response = await this.blogger.blogs.getByUrl({
123
- url
124
- });
84
+ const response = await this.blogger.blogs.getByUrl({ url });
125
85
  return response.data;
126
86
  } catch (error) {
127
87
  console.error(`Error fetching blog by URL ${url}:`, error);
@@ -129,33 +89,6 @@ export class BloggerService {
129
89
  }
130
90
  }
131
91
 
132
- /**
133
- * Simulates blog creation.
134
- * Note: The Blogger API does not actually allow creating a blog via API.
135
- * This method simulates the functionality and returns an explanatory error message.
136
- *
137
- * @param blogData Blog data to create
138
- * @returns Explanatory error message
139
- */
140
- async createBlog(blogData: Partial<BloggerBlog>): Promise<any> {
141
- // Simulate a delay to make the response more realistic
142
- await new Promise(resolve => setTimeout(resolve, 500));
143
-
144
- // Return an explanatory error message
145
- return {
146
- error: true,
147
- message: "The Google Blogger API does not allow creating a new blog via API. Please create a blog manually on blogger.com.",
148
- details: "This limitation is documented by Google. Blogs must be created via the Blogger web interface.",
149
- suggestedAction: "Create a blog at https://www.blogger.com, then use its ID with this MCP server."
150
- };
151
- }
152
-
153
- /**
154
- * Lists posts from a blog
155
- * @param blogId Blog ID
156
- * @param maxResults Maximum number of results to return
157
- * @returns Post list
158
- */
159
92
  async listPosts(blogId: string, maxResults?: number): Promise<blogger_v3.Schema$PostList> {
160
93
  try {
161
94
  const response = await this.blogger.posts.list({
@@ -169,13 +102,6 @@ export class BloggerService {
169
102
  }
170
103
  }
171
104
 
172
- /**
173
- * Searches posts in a blog using the native posts.search endpoint of the Blogger API
174
- * @param blogId Blog ID
175
- * @param query Search term
176
- * @param maxResults Maximum number of results to return
177
- * @returns List of matching posts
178
- */
179
105
  async searchPosts(blogId: string, query: string, maxResults?: number): Promise<blogger_v3.Schema$PostList> {
180
106
  try {
181
107
  const response = await this.blogger.posts.search({
@@ -184,8 +110,6 @@ export class BloggerService {
184
110
  fetchBodies: true
185
111
  });
186
112
 
187
- // The search endpoint does not support maxResults directly,
188
- // so we truncate client-side if needed
189
113
  const items = response.data.items || [];
190
114
  const limit = maxResults || config.blogger.maxResults;
191
115
 
@@ -199,18 +123,9 @@ export class BloggerService {
199
123
  }
200
124
  }
201
125
 
202
- /**
203
- * Retrieves a specific post
204
- * @param blogId Blog ID
205
- * @param postId Post ID
206
- * @returns Post details
207
- */
208
126
  async getPost(blogId: string, postId: string): Promise<blogger_v3.Schema$Post> {
209
127
  try {
210
- const response = await this.blogger.posts.get({
211
- blogId,
212
- postId
213
- });
128
+ const response = await this.blogger.posts.get({ blogId, postId });
214
129
  return response.data;
215
130
  } catch (error) {
216
131
  console.error(`Error fetching post ${postId}:`, error);
@@ -218,20 +133,15 @@ export class BloggerService {
218
133
  }
219
134
  }
220
135
 
221
- /**
222
- * Creates a new post in a blog.
223
- * Requires OAuth2.
224
- * @param blogId Blog ID
225
- * @param postData Post data to create
226
- * @returns Created post
227
- */
228
136
  async createPost(blogId: string, postData: Partial<BloggerPost>): Promise<blogger_v3.Schema$Post> {
229
137
  this.requireOAuth2('create_post');
230
138
  try {
231
- const response = await this.blogger.posts.insert({
232
- blogId,
233
- requestBody: postData as blogger_v3.Schema$Post
234
- });
139
+ const requestBody: blogger_v3.Schema$Post = {
140
+ title: postData.title ?? undefined,
141
+ content: postData.content ?? undefined,
142
+ labels: postData.labels ?? undefined
143
+ };
144
+ const response = await this.blogger.posts.insert({ blogId, requestBody });
235
145
  return response.data;
236
146
  } catch (error) {
237
147
  console.error(`Error creating post in blog ${blogId}:`, error);
@@ -239,29 +149,15 @@ export class BloggerService {
239
149
  }
240
150
  }
241
151
 
242
- /**
243
- * Updates an existing post.
244
- * Requires OAuth2.
245
- * @param blogId Blog ID
246
- * @param postId Post ID
247
- * @param postData Post data to update
248
- * @returns Updated post
249
- */
250
152
  async updatePost(blogId: string, postId: string, postData: Partial<BloggerPost>): Promise<blogger_v3.Schema$Post> {
251
153
  this.requireOAuth2('update_post');
252
154
  try {
253
- // Convert types to avoid compilation errors
254
155
  const requestBody: blogger_v3.Schema$Post = {
255
- title: postData.title,
256
- content: postData.content,
257
- labels: postData.labels
156
+ title: postData.title ?? undefined,
157
+ content: postData.content ?? undefined,
158
+ labels: postData.labels ?? undefined
258
159
  };
259
-
260
- const response = await this.blogger.posts.update({
261
- blogId,
262
- postId,
263
- requestBody
264
- });
160
+ const response = await this.blogger.posts.update({ blogId, postId, requestBody });
265
161
  return response.data;
266
162
  } catch (error) {
267
163
  console.error(`Error updating post ${postId}:`, error);
@@ -269,79 +165,48 @@ export class BloggerService {
269
165
  }
270
166
  }
271
167
 
272
- /**
273
- * Deletes a post.
274
- * Requires OAuth2.
275
- * @param blogId Blog ID
276
- * @param postId Post ID
277
- * @returns Deletion status
278
- */
279
168
  async deletePost(blogId: string, postId: string): Promise<void> {
280
169
  this.requireOAuth2('delete_post');
281
170
  try {
282
- await this.blogger.posts.delete({
283
- blogId,
284
- postId
285
- });
171
+ await this.blogger.posts.delete({ blogId, postId });
286
172
  } catch (error) {
287
173
  console.error(`Error deleting post ${postId}:`, error);
288
174
  throw error;
289
175
  }
290
176
  }
291
177
 
292
- /**
293
- * Lists labels from a blog
294
- * @param blogId Blog ID
295
- * @returns Label list
296
- */
297
178
  async listLabels(blogId: string): Promise<BloggerLabelList> {
298
179
  try {
299
- // The Blogger API does not provide a direct endpoint to list labels
300
- // We fetch all posts and extract unique labels
301
180
  const response = await this.blogger.posts.list({
302
181
  blogId,
303
- maxResults: 50 // Fetch enough posts to extract labels
182
+ maxResults: 50
304
183
  });
305
-
184
+
306
185
  const posts = response.data.items || [];
307
186
  const labelSet = new Set<string>();
308
-
309
- // Extract all unique labels from posts
187
+
310
188
  posts.forEach(post => {
311
- const postLabels = post.labels || [];
312
- postLabels.forEach(label => labelSet.add(label));
189
+ (post.labels || []).forEach(label => labelSet.add(label));
313
190
  });
314
-
315
- // Convert to expected format
191
+
316
192
  const labels = Array.from(labelSet).map(name => ({ name }));
317
-
318
- return {
319
- kind: 'blogger#labelList',
320
- items: labels
321
- };
193
+
194
+ return { kind: 'blogger#labelList', items: labels };
322
195
  } catch (error) {
323
196
  console.error(`Error fetching labels for blog ${blogId}:`, error);
324
197
  throw error;
325
198
  }
326
199
  }
327
200
 
328
- /**
329
- * Retrieves a specific label
330
- * @param blogId Blog ID
331
- * @param labelName Label name
332
- * @returns Label details
333
- */
334
- async getLabel(blogId: string, labelName: string): Promise<BloggerLabel> {
201
+ async getLabel(blogId: string, labelName: string): Promise<{ name: string }> {
335
202
  try {
336
- // The Blogger API does not provide a direct endpoint to retrieve a label
337
- // We check if the label exists by listing all labels
338
203
  const labels = await this.listLabels(blogId);
339
204
  const label = labels.items?.find(l => l.name === labelName);
340
-
205
+
341
206
  if (!label) {
342
207
  throw new Error(`Label ${labelName} not found`);
343
208
  }
344
-
209
+
345
210
  return label;
346
211
  } catch (error) {
347
212
  console.error(`Error fetching label ${labelName}:`, error);
@@ -1,29 +1,18 @@
1
- /**
2
- * Tests for config.ts
3
- *
4
- * config.ts reads process.env at module load time, so we must
5
- * set env vars BEFORE importing. We use jest.isolateModules()
6
- * to get a fresh module for each test.
7
- */
8
-
9
- // Helper to load config with specific env vars
10
1
  function loadConfig(env: Record<string, string>) {
11
- // Clean all relevant env vars first
12
2
  const keys = [
13
3
  'MCP_MODE', 'MCP_HTTP_HOST', 'MCP_HTTP_PORT',
14
4
  'BLOGGER_API_KEY', 'BLOGGER_MAX_RESULTS', 'BLOGGER_API_TIMEOUT',
15
5
  'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REFRESH_TOKEN',
16
- 'LOG_LEVEL'
6
+ 'LOG_LEVEL', 'UI_PORT'
17
7
  ];
18
8
  for (const key of keys) {
19
9
  delete process.env[key];
20
10
  }
21
- // Set requested env vars
22
11
  for (const [key, value] of Object.entries(env)) {
23
12
  process.env[key] = value;
24
13
  }
25
14
 
26
- // Force fresh import
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
16
  let config: any;
28
17
  jest.isolateModules(() => {
29
18
  config = require('./config').config;
@@ -51,6 +40,7 @@ describe('config.ts', () => {
51
40
  expect(config.oauth2.clientSecret).toBeUndefined();
52
41
  expect(config.oauth2.refreshToken).toBeUndefined();
53
42
  expect(config.logging.level).toBe('info');
43
+ expect(config.ui.port).toBe(0);
54
44
  });
55
45
  });
56
46
 
@@ -59,11 +49,6 @@ describe('config.ts', () => {
59
49
  const config = loadConfig({ MCP_MODE: 'http' });
60
50
  expect(config.mode).toBe('http');
61
51
  });
62
-
63
- it('should accept arbitrary mode values (no validation at config level)', () => {
64
- const config = loadConfig({ MCP_MODE: 'invalid' });
65
- expect(config.mode).toBe('invalid');
66
- });
67
52
  });
68
53
 
69
54
  describe('http', () => {
@@ -72,10 +57,22 @@ describe('config.ts', () => {
72
57
  expect(config.http.host).toBe('127.0.0.1');
73
58
  expect(config.http.port).toBe(8080);
74
59
  });
60
+ });
75
61
 
76
- it('should return NaN for non-numeric port', () => {
62
+ describe('safeInt (NaN protection)', () => {
63
+ it('should return default for non-numeric port', () => {
77
64
  const config = loadConfig({ MCP_HTTP_PORT: 'abc' });
78
- expect(config.http.port).toBeNaN();
65
+ expect(config.http.port).toBe(3000);
66
+ });
67
+
68
+ it('should return default for non-numeric maxResults', () => {
69
+ const config = loadConfig({ BLOGGER_MAX_RESULTS: 'notanumber' });
70
+ expect(config.blogger.maxResults).toBe(10);
71
+ });
72
+
73
+ it('should return default for non-numeric timeout', () => {
74
+ const config = loadConfig({ BLOGGER_API_TIMEOUT: 'xyz' });
75
+ expect(config.blogger.timeout).toBe(30000);
79
76
  });
80
77
  });
81
78
 
@@ -118,4 +115,21 @@ describe('config.ts', () => {
118
115
  expect(config.logging.level).toBe('debug');
119
116
  });
120
117
  });
118
+
119
+ describe('ui', () => {
120
+ it('should default to port 0 (disabled)', () => {
121
+ const config = loadConfig({});
122
+ expect(config.ui.port).toBe(0);
123
+ });
124
+
125
+ it('should read UI_PORT from env', () => {
126
+ const config = loadConfig({ UI_PORT: '4000' });
127
+ expect(config.ui.port).toBe(4000);
128
+ });
129
+
130
+ it('should return default 0 for non-numeric UI_PORT', () => {
131
+ const config = loadConfig({ UI_PORT: 'abc' });
132
+ expect(config.ui.port).toBe(0);
133
+ });
134
+ });
121
135
  });
package/src/config.ts CHANGED
@@ -1,33 +1,34 @@
1
- // MCP server configuration for Blogger
1
+ function safeInt(value: string | undefined, defaultValue: number): number {
2
+ if (!value) return defaultValue;
3
+ const parsed = parseInt(value, 10);
4
+ return Number.isNaN(parsed) ? defaultValue : parsed;
5
+ }
6
+
2
7
  export const config = {
3
- // Server operating mode (stdio or http)
4
8
  mode: process.env.MCP_MODE || 'stdio',
5
-
6
- // HTTP mode configuration (if used)
9
+
7
10
  http: {
8
11
  host: process.env.MCP_HTTP_HOST || '0.0.0.0',
9
- port: parseInt(process.env.MCP_HTTP_PORT || '3000', 10)
12
+ port: safeInt(process.env.MCP_HTTP_PORT, 3000)
10
13
  },
11
-
12
- // Blogger API configuration
14
+
13
15
  blogger: {
14
16
  apiKey: process.env.BLOGGER_API_KEY,
15
- // Default maximum number of results for list queries
16
- maxResults: parseInt(process.env.BLOGGER_MAX_RESULTS || '10', 10),
17
- // API request timeout in milliseconds
18
- timeout: parseInt(process.env.BLOGGER_API_TIMEOUT || '30000', 10)
17
+ maxResults: safeInt(process.env.BLOGGER_MAX_RESULTS, 10),
18
+ timeout: safeInt(process.env.BLOGGER_API_TIMEOUT, 30000)
19
19
  },
20
-
21
- // OAuth2 configuration for authenticated operations (create, update, delete)
22
- // If these variables are not set, the server runs in read-only mode (API key)
20
+
23
21
  oauth2: {
24
22
  clientId: process.env.GOOGLE_CLIENT_ID,
25
23
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
26
24
  refreshToken: process.env.GOOGLE_REFRESH_TOKEN
27
25
  },
28
-
29
- // Logging configuration
26
+
30
27
  logging: {
31
28
  level: process.env.LOG_LEVEL || 'info'
29
+ },
30
+
31
+ ui: {
32
+ port: safeInt(process.env.UI_PORT, 0)
32
33
  }
33
34
  };