@ldraney/mcp-linkedin 0.1.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/src/index.js ADDED
@@ -0,0 +1,654 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file MCP Server for LinkedIn
5
+ * Provides tools for creating, managing, and scheduling LinkedIn posts
6
+ */
7
+
8
+ require('dotenv').config();
9
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
10
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
11
+ const {
12
+ CallToolRequestSchema,
13
+ ListToolsRequestSchema
14
+ } = require('@modelcontextprotocol/sdk/types.js');
15
+
16
+ const tools = require('./tools');
17
+ const schemas = require('./schemas');
18
+
19
+ /**
20
+ * MCP Server instance
21
+ */
22
+ const server = new Server(
23
+ {
24
+ name: 'mcp-linkedin',
25
+ version: '0.1.0'
26
+ },
27
+ {
28
+ capabilities: {
29
+ tools: {}
30
+ }
31
+ }
32
+ );
33
+
34
+ /**
35
+ * Tool definitions for MCP
36
+ */
37
+ const TOOL_DEFINITIONS = [
38
+ {
39
+ name: 'linkedin_create_post',
40
+ description: 'Create a simple text post on LinkedIn. Supports hashtags and mentions.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ commentary: {
45
+ type: 'string',
46
+ description: 'Post text content (max 3000 characters). Supports hashtags (#tag) and mentions.'
47
+ },
48
+ visibility: {
49
+ type: 'string',
50
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
51
+ default: 'PUBLIC',
52
+ description: 'Who can see the post'
53
+ }
54
+ },
55
+ required: ['commentary']
56
+ }
57
+ },
58
+ {
59
+ name: 'linkedin_create_post_with_link',
60
+ description: 'Create a LinkedIn post with a link preview (article, GitHub repo, blog post, etc.)',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ commentary: {
65
+ type: 'string',
66
+ description: 'Post text content (max 3000 characters)'
67
+ },
68
+ url: {
69
+ type: 'string',
70
+ description: 'URL to link (GitHub repo, blog post, article, etc.)'
71
+ },
72
+ title: {
73
+ type: 'string',
74
+ description: 'Custom title for link preview (optional, overrides auto-scraped title)'
75
+ },
76
+ description: {
77
+ type: 'string',
78
+ description: 'Custom description for link preview (optional)'
79
+ },
80
+ visibility: {
81
+ type: 'string',
82
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
83
+ default: 'PUBLIC',
84
+ description: 'Who can see the post'
85
+ }
86
+ },
87
+ required: ['commentary', 'url']
88
+ }
89
+ },
90
+ {
91
+ name: 'linkedin_get_my_posts',
92
+ description: 'Retrieve your recent LinkedIn posts with pagination support',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ limit: {
97
+ type: 'number',
98
+ description: 'Maximum number of posts to retrieve (1-100)',
99
+ default: 10,
100
+ minimum: 1,
101
+ maximum: 100
102
+ },
103
+ offset: {
104
+ type: 'number',
105
+ description: 'Pagination offset (skip this many posts)',
106
+ default: 0,
107
+ minimum: 0
108
+ }
109
+ }
110
+ }
111
+ },
112
+ {
113
+ name: 'linkedin_delete_post',
114
+ description: 'Delete a LinkedIn post by its URN. This operation is idempotent.',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ postUrn: {
119
+ type: 'string',
120
+ description: 'Post URN (e.g., "urn:li:share:123456" or "urn:li:ugcPost:789")'
121
+ }
122
+ },
123
+ required: ['postUrn']
124
+ }
125
+ },
126
+ {
127
+ name: 'linkedin_get_auth_url',
128
+ description: 'Generate OAuth authorization URL for LinkedIn. User must visit this URL to authorize the app.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {}
132
+ }
133
+ },
134
+ {
135
+ name: 'linkedin_exchange_code',
136
+ description: 'Exchange OAuth authorization code for access token. Call this after user authorizes the app.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ authorizationCode: {
141
+ type: 'string',
142
+ description: 'Authorization code from OAuth callback URL'
143
+ }
144
+ },
145
+ required: ['authorizationCode']
146
+ }
147
+ },
148
+ {
149
+ name: 'linkedin_get_user_info',
150
+ description: 'Get current authenticated user\'s LinkedIn profile information',
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {}
154
+ }
155
+ },
156
+ {
157
+ name: 'linkedin_update_post',
158
+ description: 'Update an existing LinkedIn post. Can modify commentary, CTA label, or landing page URL.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ postUrn: {
163
+ type: 'string',
164
+ description: 'Post URN to update (e.g., "urn:li:share:123456")'
165
+ },
166
+ commentary: {
167
+ type: 'string',
168
+ description: 'New post text (max 3000 characters)'
169
+ },
170
+ contentCallToActionLabel: {
171
+ type: 'string',
172
+ description: 'New call-to-action label'
173
+ },
174
+ contentLandingPage: {
175
+ type: 'string',
176
+ description: 'New landing page URL'
177
+ }
178
+ },
179
+ required: ['postUrn']
180
+ }
181
+ },
182
+ {
183
+ name: 'linkedin_create_post_with_image',
184
+ description: 'Create a LinkedIn post with an uploaded image. Supports PNG, JPG, and GIF formats.',
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ commentary: {
189
+ type: 'string',
190
+ description: 'Post text content (max 3000 characters)'
191
+ },
192
+ imagePath: {
193
+ type: 'string',
194
+ description: 'Local file path to the image (PNG, JPG, or GIF)'
195
+ },
196
+ altText: {
197
+ type: 'string',
198
+ description: 'Accessibility text for the image (max 300 characters)'
199
+ },
200
+ visibility: {
201
+ type: 'string',
202
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
203
+ default: 'PUBLIC',
204
+ description: 'Who can see the post'
205
+ }
206
+ },
207
+ required: ['commentary', 'imagePath']
208
+ }
209
+ },
210
+ {
211
+ name: 'linkedin_refresh_token',
212
+ description: 'Refresh an expired access token using a refresh token. LinkedIn tokens expire after 60 days.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ refreshToken: {
217
+ type: 'string',
218
+ description: 'The refresh token obtained during initial OAuth flow'
219
+ }
220
+ },
221
+ required: ['refreshToken']
222
+ }
223
+ },
224
+ {
225
+ name: 'linkedin_add_comment',
226
+ description: 'Add a comment to a LinkedIn post. Comments support up to 1250 characters.',
227
+ inputSchema: {
228
+ type: 'object',
229
+ properties: {
230
+ postUrn: {
231
+ type: 'string',
232
+ description: 'Post URN to comment on (e.g., "urn:li:share:123456" or "urn:li:ugcPost:789")'
233
+ },
234
+ text: {
235
+ type: 'string',
236
+ description: 'Comment text (max 1250 characters)'
237
+ }
238
+ },
239
+ required: ['postUrn', 'text']
240
+ }
241
+ },
242
+ {
243
+ name: 'linkedin_add_reaction',
244
+ description: 'Add a reaction to a LinkedIn post. Reaction types: LIKE (thumbs up), PRAISE (celebrate), EMPATHY (love), INTEREST (insightful), APPRECIATION (support), ENTERTAINMENT (funny).',
245
+ inputSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ postUrn: {
249
+ type: 'string',
250
+ description: 'Post URN to react to (e.g., "urn:li:share:123456" or "urn:li:ugcPost:789")'
251
+ },
252
+ reactionType: {
253
+ type: 'string',
254
+ enum: ['LIKE', 'PRAISE', 'EMPATHY', 'INTEREST', 'APPRECIATION', 'ENTERTAINMENT'],
255
+ description: 'Type of reaction: LIKE (thumbs up), PRAISE (celebrate), EMPATHY (love), INTEREST (insightful), APPRECIATION (support), ENTERTAINMENT (funny)'
256
+ }
257
+ },
258
+ required: ['postUrn', 'reactionType']
259
+ }
260
+ },
261
+ // Scheduling tools
262
+ {
263
+ name: 'linkedin_schedule_post',
264
+ description: 'Schedule a LinkedIn post for future publication. The post will be stored locally and published by the scheduler daemon when the scheduled time arrives.',
265
+ inputSchema: {
266
+ type: 'object',
267
+ properties: {
268
+ commentary: {
269
+ type: 'string',
270
+ description: 'Post text content (max 3000 characters). Supports hashtags (#tag) and mentions.'
271
+ },
272
+ scheduledTime: {
273
+ type: 'string',
274
+ description: 'ISO 8601 datetime for when to publish (must be in the future). Example: "2025-01-15T09:00:00Z"'
275
+ },
276
+ url: {
277
+ type: 'string',
278
+ description: 'Optional URL to include with link preview (for link posts)'
279
+ },
280
+ visibility: {
281
+ type: 'string',
282
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
283
+ default: 'PUBLIC',
284
+ description: 'Who can see the post'
285
+ }
286
+ },
287
+ required: ['commentary', 'scheduledTime']
288
+ }
289
+ },
290
+ {
291
+ name: 'linkedin_list_scheduled_posts',
292
+ description: 'List all scheduled posts, optionally filtered by status (pending, published, failed, cancelled).',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ status: {
297
+ type: 'string',
298
+ enum: ['pending', 'published', 'failed', 'cancelled'],
299
+ description: 'Filter by status. If not specified, returns all scheduled posts.'
300
+ },
301
+ limit: {
302
+ type: 'number',
303
+ description: 'Maximum number of posts to retrieve (1-100)',
304
+ default: 50,
305
+ minimum: 1,
306
+ maximum: 100
307
+ }
308
+ }
309
+ }
310
+ },
311
+ {
312
+ name: 'linkedin_cancel_scheduled_post',
313
+ description: 'Cancel a scheduled post before it is published. Only pending posts can be cancelled.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ properties: {
317
+ postId: {
318
+ type: 'string',
319
+ description: 'UUID of the scheduled post to cancel'
320
+ }
321
+ },
322
+ required: ['postId']
323
+ }
324
+ },
325
+ {
326
+ name: 'linkedin_get_scheduled_post',
327
+ description: 'Get details of a single scheduled post by its ID.',
328
+ inputSchema: {
329
+ type: 'object',
330
+ properties: {
331
+ postId: {
332
+ type: 'string',
333
+ description: 'UUID of the scheduled post'
334
+ }
335
+ },
336
+ required: ['postId']
337
+ }
338
+ },
339
+ // Rich media tools
340
+ {
341
+ name: 'linkedin_create_poll',
342
+ description: 'Create a LinkedIn poll post to engage your audience. Polls can have 2-4 options and run for 1 day, 3 days, 1 week, or 2 weeks.',
343
+ inputSchema: {
344
+ type: 'object',
345
+ properties: {
346
+ question: {
347
+ type: 'string',
348
+ description: 'The poll question (max 140 characters)'
349
+ },
350
+ options: {
351
+ type: 'array',
352
+ items: {
353
+ type: 'object',
354
+ properties: {
355
+ text: {
356
+ type: 'string',
357
+ description: 'Option text (max 30 characters)'
358
+ }
359
+ },
360
+ required: ['text']
361
+ },
362
+ minItems: 2,
363
+ maxItems: 4,
364
+ description: 'Poll options (2-4 choices, each max 30 characters)'
365
+ },
366
+ duration: {
367
+ type: 'string',
368
+ enum: ['ONE_DAY', 'THREE_DAYS', 'SEVEN_DAYS', 'FOURTEEN_DAYS'],
369
+ default: 'THREE_DAYS',
370
+ description: 'How long the poll runs: ONE_DAY (1 day), THREE_DAYS (3 days), SEVEN_DAYS (1 week), FOURTEEN_DAYS (2 weeks)'
371
+ },
372
+ commentary: {
373
+ type: 'string',
374
+ description: 'Optional post text to accompany the poll (max 3000 characters)'
375
+ },
376
+ visibility: {
377
+ type: 'string',
378
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
379
+ default: 'PUBLIC',
380
+ description: 'Who can see the poll'
381
+ }
382
+ },
383
+ required: ['question', 'options']
384
+ }
385
+ },
386
+ {
387
+ name: 'linkedin_create_post_with_document',
388
+ description: 'Create a LinkedIn post with an uploaded document (PDF, PPT, DOC). Great for sharing presentations, reports, and documents. Max file size: 100 MB, max pages: 300.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ commentary: {
393
+ type: 'string',
394
+ description: 'Post text content (max 3000 characters)'
395
+ },
396
+ documentPath: {
397
+ type: 'string',
398
+ description: 'Local file path to the document (PDF, DOC, DOCX, PPT, PPTX)'
399
+ },
400
+ title: {
401
+ type: 'string',
402
+ description: 'Custom title for the document (max 400 characters). Defaults to filename if not provided.'
403
+ },
404
+ visibility: {
405
+ type: 'string',
406
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
407
+ default: 'PUBLIC',
408
+ description: 'Who can see the post'
409
+ }
410
+ },
411
+ required: ['commentary', 'documentPath']
412
+ }
413
+ },
414
+ {
415
+ name: 'linkedin_create_post_with_video',
416
+ description: 'Create a LinkedIn post with an uploaded video. Supports MP4, MOV, AVI, WMV, WebM, MKV formats. Max file size: 200 MB for personal accounts.',
417
+ inputSchema: {
418
+ type: 'object',
419
+ properties: {
420
+ commentary: {
421
+ type: 'string',
422
+ description: 'Post text content (max 3000 characters)'
423
+ },
424
+ videoPath: {
425
+ type: 'string',
426
+ description: 'Local file path to the video (MP4, MOV, AVI, WMV, WebM, MKV, M4V, FLV)'
427
+ },
428
+ title: {
429
+ type: 'string',
430
+ description: 'Custom title for the video (max 400 characters). Defaults to filename if not provided.'
431
+ },
432
+ visibility: {
433
+ type: 'string',
434
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
435
+ default: 'PUBLIC',
436
+ description: 'Who can see the post'
437
+ }
438
+ },
439
+ required: ['commentary', 'videoPath']
440
+ }
441
+ },
442
+ {
443
+ name: 'linkedin_create_post_with_multi_images',
444
+ description: 'Create a LinkedIn post with multiple images (2-20 images). Great for sharing photo albums, step-by-step tutorials, or before/after comparisons.',
445
+ inputSchema: {
446
+ type: 'object',
447
+ properties: {
448
+ commentary: {
449
+ type: 'string',
450
+ description: 'Post text content (max 3000 characters)'
451
+ },
452
+ imagePaths: {
453
+ type: 'array',
454
+ items: {
455
+ type: 'string'
456
+ },
457
+ minItems: 2,
458
+ maxItems: 20,
459
+ description: 'Array of local file paths to images (2-20 images, PNG, JPG, or GIF)'
460
+ },
461
+ altTexts: {
462
+ type: 'array',
463
+ items: {
464
+ type: 'string'
465
+ },
466
+ description: 'Optional array of accessibility text for each image (max 300 characters each)'
467
+ },
468
+ visibility: {
469
+ type: 'string',
470
+ enum: ['PUBLIC', 'CONNECTIONS', 'LOGGED_IN', 'CONTAINER'],
471
+ default: 'PUBLIC',
472
+ description: 'Who can see the post'
473
+ }
474
+ },
475
+ required: ['commentary', 'imagePaths']
476
+ }
477
+ }
478
+ ];
479
+
480
+ /**
481
+ * Handle list_tools request
482
+ */
483
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
484
+ return {
485
+ tools: TOOL_DEFINITIONS
486
+ };
487
+ });
488
+
489
+ /**
490
+ * Handle call_tool request
491
+ */
492
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
493
+ const { name, arguments: args } = request.params;
494
+
495
+ try {
496
+ let result;
497
+
498
+ switch (name) {
499
+ case 'linkedin_create_post':
500
+ result = await tools.linkedin_create_post(args);
501
+ break;
502
+
503
+ case 'linkedin_create_post_with_link':
504
+ result = await tools.linkedin_create_post_with_link(args);
505
+ break;
506
+
507
+ case 'linkedin_get_my_posts':
508
+ result = await tools.linkedin_get_my_posts(args);
509
+ break;
510
+
511
+ case 'linkedin_delete_post':
512
+ result = await tools.linkedin_delete_post(args);
513
+ break;
514
+
515
+ case 'linkedin_get_auth_url':
516
+ result = await tools.linkedin_get_auth_url();
517
+ break;
518
+
519
+ case 'linkedin_exchange_code':
520
+ result = await tools.linkedin_exchange_code(args);
521
+ break;
522
+
523
+ case 'linkedin_get_user_info':
524
+ result = await tools.linkedin_get_user_info();
525
+ break;
526
+
527
+ case 'linkedin_update_post':
528
+ result = await tools.linkedin_update_post(args);
529
+ break;
530
+
531
+ case 'linkedin_create_post_with_image':
532
+ result = await tools.linkedin_create_post_with_image(args);
533
+ break;
534
+
535
+ case 'linkedin_refresh_token':
536
+ result = await tools.linkedin_refresh_token(args);
537
+ break;
538
+
539
+ case 'linkedin_add_comment':
540
+ result = await tools.linkedin_add_comment(args);
541
+ break;
542
+
543
+ case 'linkedin_add_reaction':
544
+ result = await tools.linkedin_add_reaction(args);
545
+ break;
546
+
547
+ // Scheduling tools
548
+ case 'linkedin_schedule_post':
549
+ result = await tools.linkedin_schedule_post(args);
550
+ break;
551
+
552
+ case 'linkedin_list_scheduled_posts':
553
+ result = await tools.linkedin_list_scheduled_posts(args);
554
+ break;
555
+
556
+ case 'linkedin_cancel_scheduled_post':
557
+ result = await tools.linkedin_cancel_scheduled_post(args);
558
+ break;
559
+
560
+ case 'linkedin_get_scheduled_post':
561
+ result = await tools.linkedin_get_scheduled_post(args);
562
+ break;
563
+
564
+ // Rich media tools
565
+ case 'linkedin_create_poll':
566
+ result = await tools.linkedin_create_poll(args);
567
+ break;
568
+
569
+ case 'linkedin_create_post_with_document':
570
+ result = await tools.linkedin_create_post_with_document(args);
571
+ break;
572
+
573
+ case 'linkedin_create_post_with_video':
574
+ result = await tools.linkedin_create_post_with_video(args);
575
+ break;
576
+
577
+ case 'linkedin_create_post_with_multi_images':
578
+ result = await tools.linkedin_create_post_with_multi_images(args);
579
+ break;
580
+
581
+ default:
582
+ throw new Error(`Unknown tool: ${name}`);
583
+ }
584
+
585
+ return {
586
+ content: [
587
+ {
588
+ type: 'text',
589
+ text: JSON.stringify(result, null, 2)
590
+ }
591
+ ]
592
+ };
593
+ } catch (error) {
594
+ // Handle Zod validation errors
595
+ if (error.name === 'ZodError') {
596
+ const validationErrors = error.errors.map(err =>
597
+ `${err.path.join('.')}: ${err.message}`
598
+ ).join('\n');
599
+
600
+ return {
601
+ content: [
602
+ {
603
+ type: 'text',
604
+ text: `Validation error:\n${validationErrors}`
605
+ }
606
+ ],
607
+ isError: true
608
+ };
609
+ }
610
+
611
+ // Handle API errors
612
+ if (error.response) {
613
+ return {
614
+ content: [
615
+ {
616
+ type: 'text',
617
+ text: `LinkedIn API error: ${error.message}\nStatus: ${error.response.statusCode}\nDetails: ${JSON.stringify(error.response.body, null, 2)}`
618
+ }
619
+ ],
620
+ isError: true
621
+ };
622
+ }
623
+
624
+ // Generic error
625
+ return {
626
+ content: [
627
+ {
628
+ type: 'text',
629
+ text: `Error: ${error.message}`
630
+ }
631
+ ],
632
+ isError: true
633
+ };
634
+ }
635
+ });
636
+
637
+ /**
638
+ * Start the server
639
+ */
640
+ async function main() {
641
+ const transport = new StdioServerTransport();
642
+ await server.connect(transport);
643
+
644
+ // Log to stderr (stdout is used for MCP protocol)
645
+ console.error('LinkedIn MCP server running on stdio');
646
+ console.error(`Environment: ${process.env.NODE_ENV || 'development'}`);
647
+ console.error(`Access token configured: ${!!process.env.LINKEDIN_ACCESS_TOKEN}`);
648
+ console.error(`Person ID configured: ${!!process.env.LINKEDIN_PERSON_ID}`);
649
+ }
650
+
651
+ main().catch((error) => {
652
+ console.error('Fatal error starting MCP server:', error);
653
+ process.exit(1);
654
+ });