@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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * @file LinkedIn API client wrapper
3
+ * Handles all HTTP communication with LinkedIn REST API
4
+ */
5
+
6
+ const https = require('https');
7
+ const { URLSearchParams } = require('url');
8
+
9
+ /**
10
+ * Make an HTTPS request to LinkedIn API
11
+ * @param {Object} options - Request options
12
+ * @param {string} options.method - HTTP method
13
+ * @param {string} options.path - API path
14
+ * @param {Object} [options.headers] - HTTP headers
15
+ * @param {Object|string} [options.body] - Request body
16
+ * @returns {Promise<Object>} Response data and headers
17
+ */
18
+ function makeRequest(options) {
19
+ return new Promise((resolve, reject) => {
20
+ const { method, path, headers = {}, body } = options;
21
+
22
+ const requestOptions = {
23
+ hostname: 'api.linkedin.com',
24
+ path,
25
+ method,
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ ...headers
29
+ }
30
+ };
31
+
32
+ const req = https.request(requestOptions, (res) => {
33
+ let data = '';
34
+
35
+ res.on('data', (chunk) => {
36
+ data += chunk;
37
+ });
38
+
39
+ res.on('end', () => {
40
+ const response = {
41
+ statusCode: res.statusCode,
42
+ headers: res.headers,
43
+ body: data ? (data.trim() ? JSON.parse(data) : {}) : {}
44
+ };
45
+
46
+ if (res.statusCode >= 200 && res.statusCode < 300) {
47
+ resolve(response);
48
+ } else {
49
+ const error = new Error(`LinkedIn API error: ${res.statusCode}`);
50
+ error.response = response;
51
+ reject(error);
52
+ }
53
+ });
54
+ });
55
+
56
+ req.on('error', (error) => {
57
+ reject(error);
58
+ });
59
+
60
+ if (body) {
61
+ const bodyString = typeof body === 'string' ? body : JSON.stringify(body);
62
+ req.write(bodyString);
63
+ }
64
+
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Make an OAuth token request
71
+ * @param {string} hostname - OAuth hostname
72
+ * @param {string} path - OAuth path
73
+ * @param {Object} params - URL-encoded parameters
74
+ * @returns {Promise<Object>} Token response
75
+ */
76
+ function makeOAuthRequest(hostname, path, params) {
77
+ return new Promise((resolve, reject) => {
78
+ const postData = new URLSearchParams(params).toString();
79
+
80
+ const options = {
81
+ hostname,
82
+ path,
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/x-www-form-urlencoded',
86
+ 'Content-Length': Buffer.byteLength(postData)
87
+ }
88
+ };
89
+
90
+ const req = https.request(options, (res) => {
91
+ let data = '';
92
+
93
+ res.on('data', (chunk) => {
94
+ data += chunk;
95
+ });
96
+
97
+ res.on('end', () => {
98
+ const response = JSON.parse(data);
99
+
100
+ if (res.statusCode === 200) {
101
+ resolve(response);
102
+ } else {
103
+ const error = new Error(response.error_description || 'OAuth error');
104
+ error.response = response;
105
+ reject(error);
106
+ }
107
+ });
108
+ });
109
+
110
+ req.on('error', reject);
111
+ req.write(postData);
112
+ req.end();
113
+ });
114
+ }
115
+
116
+ /**
117
+ * LinkedIn API client
118
+ */
119
+ class LinkedInAPI {
120
+ /**
121
+ * @param {Object} config
122
+ * @param {string} config.accessToken - OAuth access token
123
+ * @param {string} config.apiVersion - API version (YYYYMM format)
124
+ * @param {string} config.personId - User's person URN
125
+ */
126
+ constructor(config) {
127
+ this.accessToken = config.accessToken;
128
+ this.apiVersion = config.apiVersion;
129
+ this.personId = config.personId;
130
+ }
131
+
132
+ /**
133
+ * Create a LinkedIn post
134
+ * @param {Object} postData - Post data
135
+ * @returns {Promise<{postUrn: string, statusCode: number}>}
136
+ */
137
+ async createPost(postData) {
138
+ const response = await makeRequest({
139
+ method: 'POST',
140
+ path: '/rest/posts',
141
+ headers: {
142
+ 'Authorization': `Bearer ${this.accessToken}`,
143
+ 'LinkedIn-Version': this.apiVersion,
144
+ 'X-Restli-Protocol-Version': '2.0.0'
145
+ },
146
+ body: postData
147
+ });
148
+
149
+ return {
150
+ postUrn: response.headers['x-restli-id'],
151
+ statusCode: response.statusCode
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Get user's posts
157
+ * @param {Object} params
158
+ * @param {number} params.limit - Max posts to retrieve
159
+ * @param {number} params.offset - Pagination offset
160
+ * @returns {Promise<Object>} Posts list
161
+ */
162
+ async getPosts({ limit, offset }) {
163
+ const authorUrn = encodeURIComponent(`urn:li:person:${this.personId}`);
164
+ const path = `/rest/posts?author=${authorUrn}&q=author&start=${offset}&count=${limit}`;
165
+
166
+ const response = await makeRequest({
167
+ method: 'GET',
168
+ path,
169
+ headers: {
170
+ 'Authorization': `Bearer ${this.accessToken}`,
171
+ 'LinkedIn-Version': this.apiVersion,
172
+ 'X-Restli-Protocol-Version': '2.0.0'
173
+ }
174
+ });
175
+
176
+ return response.body;
177
+ }
178
+
179
+ /**
180
+ * Delete a post
181
+ * @param {string} postUrn - Post URN to delete
182
+ * @returns {Promise<{statusCode: number}>}
183
+ */
184
+ async deletePost(postUrn) {
185
+ const encodedUrn = encodeURIComponent(postUrn);
186
+
187
+ const response = await makeRequest({
188
+ method: 'DELETE',
189
+ path: `/rest/posts/${encodedUrn}`,
190
+ headers: {
191
+ 'Authorization': `Bearer ${this.accessToken}`,
192
+ 'LinkedIn-Version': this.apiVersion,
193
+ 'X-Restli-Protocol-Version': '2.0.0'
194
+ }
195
+ });
196
+
197
+ return {
198
+ statusCode: response.statusCode
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Update a post (partial update)
204
+ * @param {string} postUrn - Post URN to update
205
+ * @param {Object} updateData - Fields to update
206
+ * @param {string} [updateData.commentary] - New post text
207
+ * @param {string} [updateData.contentCallToActionLabel] - New CTA label
208
+ * @param {string} [updateData.contentLandingPage] - New landing page URL
209
+ * @returns {Promise<{statusCode: number}>}
210
+ */
211
+ async updatePost(postUrn, updateData) {
212
+ const encodedUrn = encodeURIComponent(postUrn);
213
+
214
+ // Build the $set object with only provided fields
215
+ const $set = {};
216
+ if (updateData.commentary !== undefined) {
217
+ $set.commentary = updateData.commentary;
218
+ }
219
+ if (updateData.contentCallToActionLabel !== undefined) {
220
+ $set.contentCallToActionLabel = updateData.contentCallToActionLabel;
221
+ }
222
+ if (updateData.contentLandingPage !== undefined) {
223
+ $set.contentLandingPage = updateData.contentLandingPage;
224
+ }
225
+
226
+ const response = await makeRequest({
227
+ method: 'POST',
228
+ path: `/rest/posts/${encodedUrn}`,
229
+ headers: {
230
+ 'Authorization': `Bearer ${this.accessToken}`,
231
+ 'LinkedIn-Version': this.apiVersion,
232
+ 'X-Restli-Protocol-Version': '2.0.0',
233
+ 'X-RestLi-Method': 'PARTIAL_UPDATE'
234
+ },
235
+ body: {
236
+ patch: { $set }
237
+ }
238
+ });
239
+
240
+ return {
241
+ statusCode: response.statusCode
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Initialize image upload to get upload URL
247
+ * @returns {Promise<{uploadUrl: string, imageUrn: string}>}
248
+ */
249
+ async initializeImageUpload() {
250
+ const response = await makeRequest({
251
+ method: 'POST',
252
+ path: '/rest/images?action=initializeUpload',
253
+ headers: {
254
+ 'Authorization': `Bearer ${this.accessToken}`,
255
+ 'LinkedIn-Version': this.apiVersion,
256
+ 'X-Restli-Protocol-Version': '2.0.0'
257
+ },
258
+ body: {
259
+ initializeUploadRequest: {
260
+ owner: `urn:li:person:${this.personId}`
261
+ }
262
+ }
263
+ });
264
+
265
+ return {
266
+ uploadUrl: response.body.value.uploadUrl,
267
+ imageUrn: response.body.value.image
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Upload image binary to LinkedIn
273
+ * @param {string} uploadUrl - URL from initializeImageUpload
274
+ * @param {Buffer} imageBuffer - Image binary data
275
+ * @param {string} contentType - MIME type (image/png, image/jpeg, image/gif)
276
+ * @returns {Promise<{statusCode: number}>}
277
+ */
278
+ async uploadImageBinary(uploadUrl, imageBuffer, contentType) {
279
+ return new Promise((resolve, reject) => {
280
+ const url = new URL(uploadUrl);
281
+
282
+ const options = {
283
+ hostname: url.hostname,
284
+ path: url.pathname + url.search,
285
+ method: 'PUT',
286
+ headers: {
287
+ 'Authorization': `Bearer ${this.accessToken}`,
288
+ 'Content-Type': contentType,
289
+ 'Content-Length': imageBuffer.length
290
+ }
291
+ };
292
+
293
+ const req = https.request(options, (res) => {
294
+ let data = '';
295
+ res.on('data', chunk => data += chunk);
296
+ res.on('end', () => {
297
+ if (res.statusCode >= 200 && res.statusCode < 300) {
298
+ resolve({ statusCode: res.statusCode });
299
+ } else {
300
+ const error = new Error(`Image upload failed: ${res.statusCode}`);
301
+ error.response = { statusCode: res.statusCode, body: data };
302
+ reject(error);
303
+ }
304
+ });
305
+ });
306
+
307
+ req.on('error', reject);
308
+ req.write(imageBuffer);
309
+ req.end();
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Refresh access token using refresh token
315
+ * @param {Object} params
316
+ * @param {string} params.refreshToken - Refresh token
317
+ * @param {string} params.clientId - OAuth client ID
318
+ * @param {string} params.clientSecret - OAuth client secret
319
+ * @returns {Promise<Object>} New token response
320
+ */
321
+ static async refreshAccessToken({ refreshToken, clientId, clientSecret }) {
322
+ return makeOAuthRequest('www.linkedin.com', '/oauth/v2/accessToken', {
323
+ grant_type: 'refresh_token',
324
+ refresh_token: refreshToken,
325
+ client_id: clientId,
326
+ client_secret: clientSecret
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Get user info from userinfo endpoint
332
+ * @returns {Promise<Object>} User information
333
+ */
334
+ async getUserInfo() {
335
+ const response = await makeRequest({
336
+ method: 'GET',
337
+ path: '/v2/userinfo',
338
+ headers: {
339
+ 'Authorization': `Bearer ${this.accessToken}`
340
+ }
341
+ });
342
+
343
+ return response.body;
344
+ }
345
+
346
+ /**
347
+ * Exchange authorization code for access token
348
+ * @param {Object} params
349
+ * @param {string} params.code - Authorization code
350
+ * @param {string} params.clientId - OAuth client ID
351
+ * @param {string} params.clientSecret - OAuth client secret
352
+ * @param {string} params.redirectUri - OAuth redirect URI
353
+ * @returns {Promise<Object>} Token response
354
+ */
355
+ static async exchangeAuthCode({ code, clientId, clientSecret, redirectUri }) {
356
+ return makeOAuthRequest('www.linkedin.com', '/oauth/v2/accessToken', {
357
+ grant_type: 'authorization_code',
358
+ code,
359
+ client_id: clientId,
360
+ client_secret: clientSecret,
361
+ redirect_uri: redirectUri
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Add a comment to a post
367
+ * @param {string} postUrn - Post URN to comment on
368
+ * @param {string} text - Comment text
369
+ * @returns {Promise<{commentUrn: string, statusCode: number}>}
370
+ */
371
+ async addComment(postUrn, text) {
372
+ const encodedUrn = encodeURIComponent(postUrn);
373
+
374
+ const response = await makeRequest({
375
+ method: 'POST',
376
+ path: `/rest/socialActions/${encodedUrn}/comments`,
377
+ headers: {
378
+ 'Authorization': `Bearer ${this.accessToken}`,
379
+ 'LinkedIn-Version': this.apiVersion,
380
+ 'X-Restli-Protocol-Version': '2.0.0'
381
+ },
382
+ body: {
383
+ actor: `urn:li:person:${this.personId}`,
384
+ message: {
385
+ text
386
+ }
387
+ }
388
+ });
389
+
390
+ return {
391
+ commentUrn: response.headers['x-restli-id'],
392
+ statusCode: response.statusCode
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Add a reaction to a post
398
+ * @param {string} postUrn - Post URN to react to
399
+ * @param {string} reactionType - Type of reaction (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
400
+ * @returns {Promise<{statusCode: number}>}
401
+ */
402
+ async addReaction(postUrn, reactionType) {
403
+ const actorUrn = encodeURIComponent(`urn:li:person:${this.personId}`);
404
+
405
+ const response = await makeRequest({
406
+ method: 'POST',
407
+ path: `/rest/reactions?actor=${actorUrn}`,
408
+ headers: {
409
+ 'Authorization': `Bearer ${this.accessToken}`,
410
+ 'LinkedIn-Version': this.apiVersion,
411
+ 'X-Restli-Protocol-Version': '2.0.0'
412
+ },
413
+ body: {
414
+ root: postUrn,
415
+ reactionType
416
+ }
417
+ });
418
+
419
+ return {
420
+ statusCode: response.statusCode
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Initialize document upload to get upload URL
426
+ * @returns {Promise<{uploadUrl: string, documentUrn: string}>}
427
+ */
428
+ async initializeDocumentUpload() {
429
+ const response = await makeRequest({
430
+ method: 'POST',
431
+ path: '/rest/documents?action=initializeUpload',
432
+ headers: {
433
+ 'Authorization': `Bearer ${this.accessToken}`,
434
+ 'LinkedIn-Version': this.apiVersion,
435
+ 'X-Restli-Protocol-Version': '2.0.0'
436
+ },
437
+ body: {
438
+ initializeUploadRequest: {
439
+ owner: `urn:li:person:${this.personId}`
440
+ }
441
+ }
442
+ });
443
+
444
+ return {
445
+ uploadUrl: response.body.value.uploadUrl,
446
+ documentUrn: response.body.value.document
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Upload document binary to LinkedIn
452
+ * @param {string} uploadUrl - URL from initializeDocumentUpload
453
+ * @param {Buffer} documentBuffer - Document binary data
454
+ * @param {string} contentType - MIME type
455
+ * @returns {Promise<{statusCode: number}>}
456
+ */
457
+ async uploadDocumentBinary(uploadUrl, documentBuffer, contentType) {
458
+ return new Promise((resolve, reject) => {
459
+ const url = new URL(uploadUrl);
460
+
461
+ const options = {
462
+ hostname: url.hostname,
463
+ path: url.pathname + url.search,
464
+ method: 'PUT',
465
+ headers: {
466
+ 'Authorization': `Bearer ${this.accessToken}`,
467
+ 'Content-Type': contentType,
468
+ 'Content-Length': documentBuffer.length
469
+ }
470
+ };
471
+
472
+ const req = https.request(options, (res) => {
473
+ let data = '';
474
+ res.on('data', chunk => data += chunk);
475
+ res.on('end', () => {
476
+ if (res.statusCode >= 200 && res.statusCode < 300) {
477
+ resolve({ statusCode: res.statusCode });
478
+ } else {
479
+ const error = new Error(`Document upload failed: ${res.statusCode}`);
480
+ error.response = { statusCode: res.statusCode, body: data };
481
+ reject(error);
482
+ }
483
+ });
484
+ });
485
+
486
+ req.on('error', reject);
487
+ req.write(documentBuffer);
488
+ req.end();
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Initialize video upload to get upload URL and video URN
494
+ * @param {number} fileSizeBytes - Size of video file in bytes
495
+ * @returns {Promise<{uploadUrl: string, videoUrn: string}>}
496
+ */
497
+ async initializeVideoUpload(fileSizeBytes) {
498
+ const response = await makeRequest({
499
+ method: 'POST',
500
+ path: '/rest/videos?action=initializeUpload',
501
+ headers: {
502
+ 'Authorization': `Bearer ${this.accessToken}`,
503
+ 'LinkedIn-Version': this.apiVersion,
504
+ 'X-Restli-Protocol-Version': '2.0.0'
505
+ },
506
+ body: {
507
+ initializeUploadRequest: {
508
+ owner: `urn:li:person:${this.personId}`,
509
+ fileSizeBytes,
510
+ uploadCaptions: false,
511
+ uploadThumbnail: false
512
+ }
513
+ }
514
+ });
515
+
516
+ return {
517
+ uploadUrl: response.body.value.uploadInstructions[0].uploadUrl,
518
+ videoUrn: response.body.value.video
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Upload video binary to LinkedIn
524
+ * @param {string} uploadUrl - URL from initializeVideoUpload
525
+ * @param {Buffer} videoBuffer - Video binary data
526
+ * @param {string} contentType - MIME type
527
+ * @returns {Promise<{statusCode: number, etag: string}>}
528
+ */
529
+ async uploadVideoBinary(uploadUrl, videoBuffer, contentType) {
530
+ return new Promise((resolve, reject) => {
531
+ const url = new URL(uploadUrl);
532
+
533
+ const options = {
534
+ hostname: url.hostname,
535
+ path: url.pathname + url.search,
536
+ method: 'PUT',
537
+ headers: {
538
+ 'Authorization': `Bearer ${this.accessToken}`,
539
+ 'Content-Type': contentType,
540
+ 'Content-Length': videoBuffer.length
541
+ }
542
+ };
543
+
544
+ const req = https.request(options, (res) => {
545
+ let data = '';
546
+ res.on('data', chunk => data += chunk);
547
+ res.on('end', () => {
548
+ if (res.statusCode >= 200 && res.statusCode < 300) {
549
+ resolve({
550
+ statusCode: res.statusCode,
551
+ etag: res.headers['etag'] || ''
552
+ });
553
+ } else {
554
+ const error = new Error(`Video upload failed: ${res.statusCode}`);
555
+ error.response = { statusCode: res.statusCode, body: data };
556
+ reject(error);
557
+ }
558
+ });
559
+ });
560
+
561
+ req.on('error', reject);
562
+ req.write(videoBuffer);
563
+ req.end();
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Finalize video upload after binary upload is complete
569
+ * @param {string} videoUrn - Video URN from initializeVideoUpload
570
+ * @param {string} uploadUrl - Upload URL used
571
+ * @param {string} etag - ETag from upload response
572
+ * @returns {Promise<{statusCode: number}>}
573
+ */
574
+ async finalizeVideoUpload(videoUrn, uploadUrl, etag) {
575
+ const response = await makeRequest({
576
+ method: 'POST',
577
+ path: '/rest/videos?action=finalizeUpload',
578
+ headers: {
579
+ 'Authorization': `Bearer ${this.accessToken}`,
580
+ 'LinkedIn-Version': this.apiVersion,
581
+ 'X-Restli-Protocol-Version': '2.0.0'
582
+ },
583
+ body: {
584
+ finalizeUploadRequest: {
585
+ video: videoUrn,
586
+ uploadToken: '',
587
+ uploadedPartIds: [etag]
588
+ }
589
+ }
590
+ });
591
+
592
+ return {
593
+ statusCode: response.statusCode
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Initialize multi-image upload for batch image upload
599
+ * @param {number} imageCount - Number of images to upload
600
+ * @returns {Promise<Array<{uploadUrl: string, imageUrn: string}>>}
601
+ */
602
+ async initializeMultiImageUpload(imageCount) {
603
+ const results = [];
604
+
605
+ for (let i = 0; i < imageCount; i++) {
606
+ const response = await makeRequest({
607
+ method: 'POST',
608
+ path: '/rest/images?action=initializeUpload',
609
+ headers: {
610
+ 'Authorization': `Bearer ${this.accessToken}`,
611
+ 'LinkedIn-Version': this.apiVersion,
612
+ 'X-Restli-Protocol-Version': '2.0.0'
613
+ },
614
+ body: {
615
+ initializeUploadRequest: {
616
+ owner: `urn:li:person:${this.personId}`
617
+ }
618
+ }
619
+ });
620
+
621
+ results.push({
622
+ uploadUrl: response.body.value.uploadUrl,
623
+ imageUrn: response.body.value.image
624
+ });
625
+ }
626
+
627
+ return results;
628
+ }
629
+ }
630
+
631
+ module.exports = LinkedInAPI;