connected-workspace-mcp 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/.env.example +17 -0
- package/CONTRIBUTING.md +47 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/src/auth/google/authorize.js +71 -0
- package/dist/src/auth/google/index.js +27 -0
- package/dist/src/auth/linkedin/authorize.js +93 -0
- package/dist/src/auth/linkedin/index.js +16 -0
- package/dist/src/auth/token-store.js +36 -0
- package/dist/src/config/arguments.js +19 -0
- package/dist/src/config/bootstrap.js +2 -0
- package/dist/src/config/environment.js +14 -0
- package/dist/src/index.js +64 -0
- package/dist/src/logging/logger.js +66 -0
- package/dist/src/tools/calendar/index.js +123 -0
- package/dist/src/tools/gmail/index.js +177 -0
- package/dist/src/tools/linkedin/client.js +122 -0
- package/dist/src/tools/linkedin/index.js +52 -0
- package/dist/src/tools/shared.js +33 -0
- package/dist/src/utils/common.js +25 -0
- package/docs/README.md +25 -0
- package/docs/configuration.md +59 -0
- package/docs/google-auth.md +60 -0
- package/docs/installation.md +84 -0
- package/docs/linkedin-auth.md +61 -0
- package/docs/publishing.md +52 -0
- package/docs/tools.md +40 -0
- package/docs/troubleshooting.md +61 -0
- package/package.json +79 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { jsonText, runTool } from '../shared.js';
|
|
3
|
+
export function registerCalendarTools(server, calendar) {
|
|
4
|
+
server.registerTool('calendar_list_events', {
|
|
5
|
+
description: 'List Google Calendar events in a date/time range.',
|
|
6
|
+
inputSchema: z.object({
|
|
7
|
+
calendarId: z.string().default('primary'),
|
|
8
|
+
timeMin: z.iso.datetime(),
|
|
9
|
+
timeMax: z.iso.datetime(),
|
|
10
|
+
query: z.string().optional(),
|
|
11
|
+
maxResults: z.number().int().min(1).max(250).default(50),
|
|
12
|
+
}),
|
|
13
|
+
}, async ({ calendarId, timeMin, timeMax, query, maxResults }) => runTool('calendar_list_events', async () => {
|
|
14
|
+
const result = await calendar.events.list({
|
|
15
|
+
calendarId,
|
|
16
|
+
timeMin,
|
|
17
|
+
timeMax,
|
|
18
|
+
q: query,
|
|
19
|
+
maxResults,
|
|
20
|
+
singleEvents: true,
|
|
21
|
+
orderBy: 'startTime',
|
|
22
|
+
});
|
|
23
|
+
return jsonText(result.data.items || []);
|
|
24
|
+
}));
|
|
25
|
+
server.registerTool('calendar_create_event', {
|
|
26
|
+
description: 'Schedule an event on Google Calendar.',
|
|
27
|
+
inputSchema: z.object({
|
|
28
|
+
calendarId: z.string().default('primary'),
|
|
29
|
+
summary: z.string().min(1),
|
|
30
|
+
description: z.string().optional(),
|
|
31
|
+
location: z.string().optional(),
|
|
32
|
+
start: z.iso.datetime(),
|
|
33
|
+
end: z.iso.datetime(),
|
|
34
|
+
timeZone: z.string().optional(),
|
|
35
|
+
attendees: z.array(z.string().email()).default([]),
|
|
36
|
+
sendUpdates: z.enum(['all', 'externalOnly', 'none']).default('all'),
|
|
37
|
+
}),
|
|
38
|
+
}, async ({ calendarId, summary, description, location, start, end, timeZone, attendees, sendUpdates, }) => runTool('calendar_create_event', async () => {
|
|
39
|
+
const result = await calendar.events.insert({
|
|
40
|
+
calendarId,
|
|
41
|
+
sendUpdates,
|
|
42
|
+
requestBody: {
|
|
43
|
+
summary,
|
|
44
|
+
description,
|
|
45
|
+
location,
|
|
46
|
+
start: { dateTime: start, timeZone },
|
|
47
|
+
end: { dateTime: end, timeZone },
|
|
48
|
+
attendees: attendees.map((email) => ({ email })),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return jsonText({
|
|
52
|
+
id: result.data.id,
|
|
53
|
+
htmlLink: result.data.htmlLink,
|
|
54
|
+
status: result.data.status,
|
|
55
|
+
});
|
|
56
|
+
}));
|
|
57
|
+
server.registerTool('calendar_update_event', {
|
|
58
|
+
description: 'Update fields on an existing Google Calendar event.',
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
calendarId: z.string().default('primary'),
|
|
61
|
+
eventId: z.string().min(1),
|
|
62
|
+
summary: z.string().min(1).optional(),
|
|
63
|
+
description: z.string().optional(),
|
|
64
|
+
location: z.string().optional(),
|
|
65
|
+
start: z.iso.datetime().optional(),
|
|
66
|
+
end: z.iso.datetime().optional(),
|
|
67
|
+
timeZone: z.string().optional(),
|
|
68
|
+
attendees: z.array(z.string().email()).optional(),
|
|
69
|
+
sendUpdates: z.enum(['all', 'externalOnly', 'none']).default('all'),
|
|
70
|
+
}),
|
|
71
|
+
}, async ({ calendarId, eventId, summary, description, location, start, end, timeZone, attendees, sendUpdates, }) => runTool('calendar_update_event', async () => {
|
|
72
|
+
const result = await calendar.events.patch({
|
|
73
|
+
calendarId,
|
|
74
|
+
eventId,
|
|
75
|
+
sendUpdates,
|
|
76
|
+
requestBody: {
|
|
77
|
+
summary,
|
|
78
|
+
description,
|
|
79
|
+
location,
|
|
80
|
+
...(start ? { start: { dateTime: start, timeZone } } : {}),
|
|
81
|
+
...(end ? { end: { dateTime: end, timeZone } } : {}),
|
|
82
|
+
...(attendees
|
|
83
|
+
? { attendees: attendees.map((email) => ({ email })) }
|
|
84
|
+
: {}),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return jsonText({
|
|
88
|
+
id: result.data.id,
|
|
89
|
+
htmlLink: result.data.htmlLink,
|
|
90
|
+
status: result.data.status,
|
|
91
|
+
});
|
|
92
|
+
}));
|
|
93
|
+
server.registerTool('calendar_delete_event', {
|
|
94
|
+
description: 'Delete an event from Google Calendar.',
|
|
95
|
+
inputSchema: z.object({
|
|
96
|
+
calendarId: z.string().default('primary'),
|
|
97
|
+
eventId: z.string().min(1),
|
|
98
|
+
sendUpdates: z.enum(['all', 'externalOnly', 'none']).default('all'),
|
|
99
|
+
}),
|
|
100
|
+
}, async ({ calendarId, eventId, sendUpdates }) => runTool('calendar_delete_event', async () => {
|
|
101
|
+
await calendar.events.delete({ calendarId, eventId, sendUpdates });
|
|
102
|
+
return jsonText({ deleted: true, eventId });
|
|
103
|
+
}));
|
|
104
|
+
server.registerTool('calendar_free_busy', {
|
|
105
|
+
description: 'Read busy periods for one or more calendars in a date/time range.',
|
|
106
|
+
inputSchema: z.object({
|
|
107
|
+
calendarIds: z.array(z.string()).min(1).default(['primary']),
|
|
108
|
+
timeMin: z.iso.datetime(),
|
|
109
|
+
timeMax: z.iso.datetime(),
|
|
110
|
+
timeZone: z.string().optional(),
|
|
111
|
+
}),
|
|
112
|
+
}, async ({ calendarIds, timeMin, timeMax, timeZone }) => runTool('calendar_free_busy', async () => {
|
|
113
|
+
const result = await calendar.freebusy.query({
|
|
114
|
+
requestBody: {
|
|
115
|
+
timeMin,
|
|
116
|
+
timeMax,
|
|
117
|
+
timeZone,
|
|
118
|
+
items: calendarIds.map((id) => ({ id })),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return jsonText(result.data.calendars || {});
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { jsonText, runTool } from '../shared.js';
|
|
3
|
+
function encodeMessage(message) {
|
|
4
|
+
return Buffer.from(message)
|
|
5
|
+
.toString('base64')
|
|
6
|
+
.replaceAll('+', '-')
|
|
7
|
+
.replaceAll('/', '_')
|
|
8
|
+
.replace(/=+$/, '');
|
|
9
|
+
}
|
|
10
|
+
function mimeMessage(input) {
|
|
11
|
+
const headers = [
|
|
12
|
+
`To: ${input.to.join(', ')}`,
|
|
13
|
+
...(input.cc?.length ? [`Cc: ${input.cc.join(', ')}`] : []),
|
|
14
|
+
...(input.bcc?.length ? [`Bcc: ${input.bcc.join(', ')}`] : []),
|
|
15
|
+
`Subject: ${input.subject}`,
|
|
16
|
+
...(input.inReplyTo ? [`In-Reply-To: ${input.inReplyTo}`] : []),
|
|
17
|
+
...(input.references ? [`References: ${input.references}`] : []),
|
|
18
|
+
'MIME-Version: 1.0',
|
|
19
|
+
'Content-Type: text/plain; charset=UTF-8',
|
|
20
|
+
'Content-Transfer-Encoding: 8bit',
|
|
21
|
+
];
|
|
22
|
+
return [...headers, '', input.body].join('\r\n');
|
|
23
|
+
}
|
|
24
|
+
function decodedBody(part) {
|
|
25
|
+
if (!part)
|
|
26
|
+
return '';
|
|
27
|
+
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
28
|
+
return Buffer.from(part.body.data, 'base64url').toString('utf8');
|
|
29
|
+
}
|
|
30
|
+
for (const child of part.parts || []) {
|
|
31
|
+
const body = decodedBody(child);
|
|
32
|
+
if (body)
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
if (part.body?.data)
|
|
36
|
+
return Buffer.from(part.body.data, 'base64url').toString('utf8');
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
function headers(message) {
|
|
40
|
+
return Object.fromEntries((message.payload?.headers || [])
|
|
41
|
+
.filter((header) => header.name && header.value)
|
|
42
|
+
.map((header) => [header.name.toLowerCase(), header.value]));
|
|
43
|
+
}
|
|
44
|
+
const recipientsSchema = {
|
|
45
|
+
to: z.array(z.string().email()).min(1),
|
|
46
|
+
cc: z.array(z.string().email()).default([]),
|
|
47
|
+
bcc: z.array(z.string().email()).default([]),
|
|
48
|
+
subject: z.string().min(1),
|
|
49
|
+
body: z.string(),
|
|
50
|
+
};
|
|
51
|
+
export function registerGmailTools(server, gmail) {
|
|
52
|
+
server.registerTool('gmail_search', {
|
|
53
|
+
description: 'Search Gmail using Gmail query syntax and return message summaries.',
|
|
54
|
+
inputSchema: z.object({
|
|
55
|
+
query: z.string().default('in:inbox'),
|
|
56
|
+
maxResults: z.number().int().min(1).max(100).default(20),
|
|
57
|
+
pageToken: z.string().optional(),
|
|
58
|
+
}),
|
|
59
|
+
}, async ({ query, maxResults, pageToken }) => runTool('gmail_search', async () => {
|
|
60
|
+
const result = await gmail.users.messages.list({
|
|
61
|
+
userId: 'me',
|
|
62
|
+
q: query,
|
|
63
|
+
maxResults,
|
|
64
|
+
pageToken,
|
|
65
|
+
});
|
|
66
|
+
const messages = await Promise.all((result.data.messages || []).map(async ({ id }) => {
|
|
67
|
+
const response = await gmail.users.messages.get({
|
|
68
|
+
userId: 'me',
|
|
69
|
+
id: id,
|
|
70
|
+
format: 'metadata',
|
|
71
|
+
metadataHeaders: ['From', 'To', 'Cc', 'Subject', 'Date'],
|
|
72
|
+
});
|
|
73
|
+
const messageHeaders = headers(response.data);
|
|
74
|
+
return {
|
|
75
|
+
id: response.data.id,
|
|
76
|
+
threadId: response.data.threadId,
|
|
77
|
+
from: messageHeaders.from,
|
|
78
|
+
to: messageHeaders.to,
|
|
79
|
+
cc: messageHeaders.cc,
|
|
80
|
+
subject: messageHeaders.subject,
|
|
81
|
+
date: messageHeaders.date,
|
|
82
|
+
snippet: response.data.snippet,
|
|
83
|
+
labelIds: response.data.labelIds,
|
|
84
|
+
};
|
|
85
|
+
}));
|
|
86
|
+
return jsonText({ messages, nextPageToken: result.data.nextPageToken });
|
|
87
|
+
}));
|
|
88
|
+
server.registerTool('gmail_get_message', {
|
|
89
|
+
description: 'Read one Gmail message with headers and decoded body text.',
|
|
90
|
+
inputSchema: z.object({ messageId: z.string().min(1) }),
|
|
91
|
+
}, async ({ messageId }) => runTool('gmail_get_message', async () => {
|
|
92
|
+
const result = await gmail.users.messages.get({
|
|
93
|
+
userId: 'me',
|
|
94
|
+
id: messageId,
|
|
95
|
+
format: 'full',
|
|
96
|
+
});
|
|
97
|
+
return jsonText({
|
|
98
|
+
id: result.data.id,
|
|
99
|
+
threadId: result.data.threadId,
|
|
100
|
+
headers: headers(result.data),
|
|
101
|
+
body: decodedBody(result.data.payload),
|
|
102
|
+
snippet: result.data.snippet,
|
|
103
|
+
labelIds: result.data.labelIds,
|
|
104
|
+
});
|
|
105
|
+
}));
|
|
106
|
+
server.registerTool('gmail_send_message', {
|
|
107
|
+
description: 'Send a plain-text email from the authenticated Gmail account.',
|
|
108
|
+
inputSchema: z.object(recipientsSchema),
|
|
109
|
+
}, async (input) => runTool('gmail_send_message', async () => {
|
|
110
|
+
const result = await gmail.users.messages.send({
|
|
111
|
+
userId: 'me',
|
|
112
|
+
requestBody: { raw: encodeMessage(mimeMessage(input)) },
|
|
113
|
+
});
|
|
114
|
+
return jsonText({
|
|
115
|
+
id: result.data.id,
|
|
116
|
+
threadId: result.data.threadId,
|
|
117
|
+
labelIds: result.data.labelIds,
|
|
118
|
+
});
|
|
119
|
+
}));
|
|
120
|
+
server.registerTool('gmail_reply', {
|
|
121
|
+
description: 'Reply to a Gmail message in its existing thread.',
|
|
122
|
+
inputSchema: z.object({
|
|
123
|
+
messageId: z.string().min(1),
|
|
124
|
+
body: z.string().min(1),
|
|
125
|
+
}),
|
|
126
|
+
}, async ({ messageId, body }) => runTool('gmail_reply', async () => {
|
|
127
|
+
const original = await gmail.users.messages.get({
|
|
128
|
+
userId: 'me',
|
|
129
|
+
id: messageId,
|
|
130
|
+
format: 'metadata',
|
|
131
|
+
metadataHeaders: [
|
|
132
|
+
'From',
|
|
133
|
+
'Reply-To',
|
|
134
|
+
'Subject',
|
|
135
|
+
'Message-ID',
|
|
136
|
+
'References',
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
const originalHeaders = headers(original.data);
|
|
140
|
+
const subject = originalHeaders.subject?.toLowerCase().startsWith('re:')
|
|
141
|
+
? originalHeaders.subject
|
|
142
|
+
: `Re: ${originalHeaders.subject || ''}`;
|
|
143
|
+
const messageIdHeader = originalHeaders['message-id'];
|
|
144
|
+
const raw = mimeMessage({
|
|
145
|
+
to: [originalHeaders['reply-to'] || originalHeaders.from],
|
|
146
|
+
subject,
|
|
147
|
+
body,
|
|
148
|
+
inReplyTo: messageIdHeader,
|
|
149
|
+
references: [originalHeaders.references, messageIdHeader]
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join(' '),
|
|
152
|
+
});
|
|
153
|
+
const result = await gmail.users.messages.send({
|
|
154
|
+
userId: 'me',
|
|
155
|
+
requestBody: {
|
|
156
|
+
raw: encodeMessage(raw),
|
|
157
|
+
threadId: original.data.threadId,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
return jsonText({ id: result.data.id, threadId: result.data.threadId });
|
|
161
|
+
}));
|
|
162
|
+
server.registerTool('gmail_modify_message', {
|
|
163
|
+
description: 'Add or remove Gmail labels such as UNREAD, STARRED, INBOX, or TRASH.',
|
|
164
|
+
inputSchema: z.object({
|
|
165
|
+
messageId: z.string().min(1),
|
|
166
|
+
addLabelIds: z.array(z.string()).default([]),
|
|
167
|
+
removeLabelIds: z.array(z.string()).default([]),
|
|
168
|
+
}),
|
|
169
|
+
}, async ({ messageId, addLabelIds, removeLabelIds }) => runTool('gmail_modify_message', async () => {
|
|
170
|
+
const result = await gmail.users.messages.modify({
|
|
171
|
+
userId: 'me',
|
|
172
|
+
id: messageId,
|
|
173
|
+
requestBody: { addLabelIds, removeLabelIds },
|
|
174
|
+
});
|
|
175
|
+
return jsonText({ id: result.data.id, labelIds: result.data.labelIds });
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { createLinkedInAuth } from '../../auth/linkedin/index.js';
|
|
4
|
+
const API_BASE = 'https://api.linkedin.com';
|
|
5
|
+
function imageContentType(path) {
|
|
6
|
+
const types = {
|
|
7
|
+
'.gif': 'image/gif',
|
|
8
|
+
'.jpeg': 'image/jpeg',
|
|
9
|
+
'.jpg': 'image/jpeg',
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
};
|
|
12
|
+
const contentType = types[extname(path).toLowerCase()];
|
|
13
|
+
if (!contentType)
|
|
14
|
+
throw new Error('LinkedIn images must be GIF, JPEG, or PNG files.');
|
|
15
|
+
return contentType;
|
|
16
|
+
}
|
|
17
|
+
export class LinkedInClient {
|
|
18
|
+
async request(path, init = {}) {
|
|
19
|
+
const auth = createLinkedInAuth();
|
|
20
|
+
const response = await fetch(`${API_BASE}${path}`, {
|
|
21
|
+
...init,
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
24
|
+
'LinkedIn-Version': auth.apiVersion,
|
|
25
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
26
|
+
...init.headers,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const responseText = await response.text();
|
|
30
|
+
const data = responseText ? JSON.parse(responseText) : {};
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(`LinkedIn API ${response.status}: ${data.message || responseText || response.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
return { data: data, headers: response.headers };
|
|
35
|
+
}
|
|
36
|
+
async getProfile() {
|
|
37
|
+
const auth = createLinkedInAuth();
|
|
38
|
+
const response = await fetch(`${API_BASE}/v2/userinfo`, {
|
|
39
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
40
|
+
});
|
|
41
|
+
const data = await response.json();
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw new Error(`LinkedIn profile API ${response.status}: ${JSON.stringify(data)}`);
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
async listPosts(count) {
|
|
47
|
+
const params = new URLSearchParams({
|
|
48
|
+
author: createLinkedInAuth().userUrn,
|
|
49
|
+
q: 'author',
|
|
50
|
+
count: String(count),
|
|
51
|
+
});
|
|
52
|
+
return (await this.request(`/rest/posts?${params}`)).data;
|
|
53
|
+
}
|
|
54
|
+
async getPost(postUrn) {
|
|
55
|
+
return (await this.request(`/rest/posts/${encodeURIComponent(postUrn)}`))
|
|
56
|
+
.data;
|
|
57
|
+
}
|
|
58
|
+
async getEngagement(postUrn) {
|
|
59
|
+
return (await this.request(`/rest/socialActions/${encodeURIComponent(postUrn)}`)).data;
|
|
60
|
+
}
|
|
61
|
+
async publishText(commentary, visibility) {
|
|
62
|
+
return this.publish(commentary, visibility);
|
|
63
|
+
}
|
|
64
|
+
async publishImage(commentary, imagePath, altText, visibility) {
|
|
65
|
+
const image = await this.initializeImageUpload();
|
|
66
|
+
const bytes = await readFile(image.uploadUrl ? imagePath : '');
|
|
67
|
+
const upload = await fetch(image.uploadUrl, {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${createLinkedInAuth().accessToken}`,
|
|
71
|
+
'Content-Type': imageContentType(imagePath),
|
|
72
|
+
},
|
|
73
|
+
body: bytes,
|
|
74
|
+
});
|
|
75
|
+
if (!upload.ok)
|
|
76
|
+
throw new Error(`LinkedIn image upload ${upload.status}: ${await upload.text()}`);
|
|
77
|
+
return this.publish(commentary, visibility, image.image, altText);
|
|
78
|
+
}
|
|
79
|
+
async deletePost(postUrn) {
|
|
80
|
+
await this.request(`/rest/posts/${encodeURIComponent(postUrn)}`, {
|
|
81
|
+
method: 'DELETE',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async initializeImageUpload() {
|
|
85
|
+
const auth = createLinkedInAuth();
|
|
86
|
+
const result = await this.request('/rest/images?action=initializeUpload', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
initializeUploadRequest: { owner: auth.userUrn },
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
return result.data.value;
|
|
94
|
+
}
|
|
95
|
+
async publish(commentary, visibility, imageUrn, altText) {
|
|
96
|
+
const auth = createLinkedInAuth();
|
|
97
|
+
const result = await this.request('/rest/posts', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
author: auth.userUrn,
|
|
102
|
+
commentary,
|
|
103
|
+
visibility,
|
|
104
|
+
distribution: {
|
|
105
|
+
feedDistribution: 'MAIN_FEED',
|
|
106
|
+
targetEntities: [],
|
|
107
|
+
thirdPartyDistributionChannels: [],
|
|
108
|
+
},
|
|
109
|
+
...(imageUrn
|
|
110
|
+
? {
|
|
111
|
+
content: {
|
|
112
|
+
media: { id: imageUrn, ...(altText ? { altText } : {}) },
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
116
|
+
lifecycleState: 'PUBLISHED',
|
|
117
|
+
isReshareDisabledByAuthor: false,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
return result.headers.get('x-restli-id') || '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { jsonText, runTool } from '../shared.js';
|
|
3
|
+
import { LinkedInClient } from './client.js';
|
|
4
|
+
export function registerLinkedInTools(server, linkedin = new LinkedInClient()) {
|
|
5
|
+
server.registerTool('linkedin_get_profile', {
|
|
6
|
+
description: 'Read the authenticated LinkedIn member profile.',
|
|
7
|
+
inputSchema: z.object({}),
|
|
8
|
+
}, async () => runTool('linkedin_get_profile', async () => jsonText(await linkedin.getProfile())));
|
|
9
|
+
server.registerTool('linkedin_list_posts', {
|
|
10
|
+
description: 'List posts authored by the authenticated LinkedIn member.',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
count: z.number().int().min(1).max(100).default(10),
|
|
13
|
+
}),
|
|
14
|
+
}, async ({ count }) => runTool('linkedin_list_posts', async () => jsonText(await linkedin.listPosts(count))));
|
|
15
|
+
server.registerTool('linkedin_get_post', {
|
|
16
|
+
description: 'Read one LinkedIn post by its full URN.',
|
|
17
|
+
inputSchema: z.object({ postUrn: z.string().min(1) }),
|
|
18
|
+
}, async ({ postUrn }) => runTool('linkedin_get_post', async () => jsonText(await linkedin.getPost(postUrn))));
|
|
19
|
+
server.registerTool('linkedin_get_engagement', {
|
|
20
|
+
description: 'Read available like and comment metrics for a LinkedIn post.',
|
|
21
|
+
inputSchema: z.object({ postUrn: z.string().min(1) }),
|
|
22
|
+
}, async ({ postUrn }) => runTool('linkedin_get_engagement', async () => jsonText(await linkedin.getEngagement(postUrn))));
|
|
23
|
+
server.registerTool('linkedin_publish_text', {
|
|
24
|
+
description: 'Publish a text post to the authenticated LinkedIn member feed.',
|
|
25
|
+
inputSchema: z.object({
|
|
26
|
+
commentary: z.string().min(1).max(3000),
|
|
27
|
+
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).default('PUBLIC'),
|
|
28
|
+
}),
|
|
29
|
+
}, async ({ commentary, visibility }) => runTool('linkedin_publish_text', async () => {
|
|
30
|
+
const postUrn = await linkedin.publishText(commentary, visibility);
|
|
31
|
+
return jsonText({ published: true, postUrn });
|
|
32
|
+
}));
|
|
33
|
+
server.registerTool('linkedin_publish_image', {
|
|
34
|
+
description: 'Upload a local GIF, JPEG, or PNG and publish it with text to LinkedIn.',
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
commentary: z.string().min(1).max(3000),
|
|
37
|
+
imagePath: z.string().min(1),
|
|
38
|
+
altText: z.string().max(4086).optional(),
|
|
39
|
+
visibility: z.enum(['PUBLIC', 'CONNECTIONS']).default('PUBLIC'),
|
|
40
|
+
}),
|
|
41
|
+
}, async ({ commentary, imagePath, altText, visibility }) => runTool('linkedin_publish_image', async () => {
|
|
42
|
+
const postUrn = await linkedin.publishImage(commentary, imagePath, altText, visibility);
|
|
43
|
+
return jsonText({ published: true, postUrn });
|
|
44
|
+
}));
|
|
45
|
+
server.registerTool('linkedin_delete_post', {
|
|
46
|
+
description: 'Permanently delete a LinkedIn post owned by the authenticated member.',
|
|
47
|
+
inputSchema: z.object({ postUrn: z.string().min(1) }),
|
|
48
|
+
}, async ({ postUrn }) => runTool('linkedin_delete_post', async () => {
|
|
49
|
+
await linkedin.deletePost(postUrn);
|
|
50
|
+
return jsonText({ deleted: true, postUrn });
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { logger, publicErrorMessage } from '../logging/logger.js';
|
|
2
|
+
export function jsonText(value) {
|
|
3
|
+
return {
|
|
4
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export async function runTool(name, operation) {
|
|
8
|
+
const startedAt = Date.now();
|
|
9
|
+
await logger.info('Tool started', { tool: name });
|
|
10
|
+
try {
|
|
11
|
+
const result = await operation();
|
|
12
|
+
await logger.info('Tool completed', {
|
|
13
|
+
tool: name,
|
|
14
|
+
durationMs: Date.now() - startedAt,
|
|
15
|
+
});
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
await logger.error('Tool failed', error, {
|
|
20
|
+
tool: name,
|
|
21
|
+
durationMs: Date.now() - startedAt,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
isError: true,
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: `Unable to complete ${name}: ${publicErrorMessage(error)}`,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
function getCurrentDir() {
|
|
5
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
}
|
|
7
|
+
function findProjectRoot(startDir) {
|
|
8
|
+
let currentDir = startDir;
|
|
9
|
+
while (currentDir !== path.parse(currentDir).root) {
|
|
10
|
+
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
|
|
11
|
+
return currentDir;
|
|
12
|
+
}
|
|
13
|
+
currentDir = path.dirname(currentDir);
|
|
14
|
+
}
|
|
15
|
+
return process.cwd();
|
|
16
|
+
}
|
|
17
|
+
export const projectRoot = findProjectRoot(getCurrentDir());
|
|
18
|
+
export function getPackageMetadata() {
|
|
19
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
20
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
21
|
+
return {
|
|
22
|
+
name: packageJson.name || 'connected-workspace-mcp',
|
|
23
|
+
version: packageJson.version || '0.1.0',
|
|
24
|
+
};
|
|
25
|
+
}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Connected Workspace MCP Documentation
|
|
2
|
+
|
|
3
|
+
Connected Workspace MCP connects MCP-compatible assistants to Gmail, Google
|
|
4
|
+
Calendar, and LinkedIn through a TypeScript stdio server.
|
|
5
|
+
|
|
6
|
+
## Guides
|
|
7
|
+
|
|
8
|
+
- [Installation and MCP host setup](installation.md)
|
|
9
|
+
- [Google OAuth setup](google-auth.md)
|
|
10
|
+
- [LinkedIn OAuth setup](linkedin-auth.md)
|
|
11
|
+
- [Configuration, tokens, and logs](configuration.md)
|
|
12
|
+
- [Tool reference](tools.md)
|
|
13
|
+
- [Authentication troubleshooting](troubleshooting.md)
|
|
14
|
+
- [Publishing to npm](publishing.md)
|
|
15
|
+
|
|
16
|
+
## Security Model
|
|
17
|
+
|
|
18
|
+
- OAuth client settings are read from environment variables or a local `.env`.
|
|
19
|
+
- Reusable provider tokens are stored outside the package directory.
|
|
20
|
+
- Tool arguments, email bodies, post text, and credentials are excluded from
|
|
21
|
+
logs.
|
|
22
|
+
- Write operations are exposed as explicit MCP tools so the host can request
|
|
23
|
+
confirmation according to its own policy.
|
|
24
|
+
|
|
25
|
+
Never commit `.env`, token files, or log files.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Storage
|
|
4
|
+
|
|
5
|
+
```dotenv
|
|
6
|
+
PA_MCP_TOKEN_PATH=C:/Users/you/.connected-workspace-mcp/tokens.json
|
|
7
|
+
PA_MCP_LOG_PATH=C:/Users/you/.connected-workspace-mcp/server.log
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
When these are omitted, the current defaults remain
|
|
11
|
+
`%USERPROFILE%/.pa-mcp/tokens.json` and `pa-mcp.log` beside it for backward
|
|
12
|
+
compatibility.
|
|
13
|
+
|
|
14
|
+
The token file contains sensitive OAuth credentials. Restrict access to your
|
|
15
|
+
user account, never commit it, and do not include it in support requests.
|
|
16
|
+
|
|
17
|
+
Logs are JSON Lines, rotate at 5 MB, and retain one rotated file. The logger
|
|
18
|
+
redacts common credential forms and never records tool arguments.
|
|
19
|
+
|
|
20
|
+
## Google
|
|
21
|
+
|
|
22
|
+
```dotenv
|
|
23
|
+
GOOGLE_CLIENT_ID=
|
|
24
|
+
GOOGLE_CLIENT_SECRET=
|
|
25
|
+
GOOGLE_REDIRECT_URI=http://localhost:3000
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`GOOGLE_REFRESH_TOKEN` is supported only as a legacy fallback. New
|
|
29
|
+
authorizations write the token store.
|
|
30
|
+
|
|
31
|
+
## LinkedIn
|
|
32
|
+
|
|
33
|
+
```dotenv
|
|
34
|
+
LINKEDIN_CLIENT_ID=
|
|
35
|
+
LINKEDIN_CLIENT_SECRET=
|
|
36
|
+
LINKEDIN_REDIRECT_URI=http://localhost:3001/callback
|
|
37
|
+
LINKEDIN_API_VERSION=202609
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`LINKEDIN_ACCESS_TOKEN` and `LINKEDIN_USER_URN` are legacy fallbacks. The OAuth
|
|
41
|
+
command persists both values in the token store.
|
|
42
|
+
|
|
43
|
+
## Loading Rules
|
|
44
|
+
|
|
45
|
+
All three executables accept either `--env-file <path>` or
|
|
46
|
+
`--env-file=<path>`. This is the recommended approach for `npx`, because its
|
|
47
|
+
working directory depends on the MCP host:
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
npx -y connected-workspace-mcp --env-file "C:\Users\you\.connected-workspace-mcp\.env"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Without this argument, the process loads `.env` from its current working
|
|
54
|
+
directory. Values already in the process environment take precedence over the
|
|
55
|
+
file. Provider tokens are loaded from the token store before legacy token
|
|
56
|
+
environment values.
|
|
57
|
+
|
|
58
|
+
Pass only the file path in command arguments. Credentials supplied directly on
|
|
59
|
+
the command line can be exposed by process inspection.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Google OAuth Setup
|
|
2
|
+
|
|
3
|
+
Gmail and Google Calendar share one OAuth client and one refresh token.
|
|
4
|
+
|
|
5
|
+
## Create The Google Client
|
|
6
|
+
|
|
7
|
+
1. Open https://console.cloud.google.com/ and select a project.
|
|
8
|
+
2. Enable **Gmail API** and **Google Calendar API**.
|
|
9
|
+
3. Configure the Google Auth Platform consent screen.
|
|
10
|
+
4. Add your Google account as a test user if the app remains in testing mode.
|
|
11
|
+
5. Create an OAuth client of type **Desktop app**.
|
|
12
|
+
6. Save the client ID and create a client secret.
|
|
13
|
+
|
|
14
|
+
Configure:
|
|
15
|
+
|
|
16
|
+
```dotenv
|
|
17
|
+
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
|
18
|
+
GOOGLE_CLIENT_SECRET=your-client-secret
|
|
19
|
+
GOOGLE_REDIRECT_URI=http://localhost:3000
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Required Scopes
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
https://www.googleapis.com/auth/gmail.modify
|
|
26
|
+
https://www.googleapis.com/auth/gmail.send
|
|
27
|
+
https://www.googleapis.com/auth/gmail.compose
|
|
28
|
+
https://www.googleapis.com/auth/calendar
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Authorize
|
|
32
|
+
|
|
33
|
+
From a source checkout:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
npm run auth:google
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
After a global npm installation:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
connected-workspace-google-auth
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Open the printed URL and approve every Gmail and Calendar permission. The flow
|
|
46
|
+
rejects partial grants and saves credentials only after all required scopes are
|
|
47
|
+
returned.
|
|
48
|
+
|
|
49
|
+
## Verify
|
|
50
|
+
|
|
51
|
+
The server automatically refreshes Google access tokens. A successful setup
|
|
52
|
+
allows `gmail_search` and `calendar_list_events` to run without another browser
|
|
53
|
+
login.
|
|
54
|
+
|
|
55
|
+
If Google returns `invalid_grant`, confirm the client ID and secret belong to
|
|
56
|
+
the same OAuth client, revoke the old grant, and authorize again. Testing-mode
|
|
57
|
+
Google refresh tokens may expire after seven days.
|
|
58
|
+
|
|
59
|
+
See [Authentication troubleshooting](troubleshooting.md) for detailed provider
|
|
60
|
+
console checks and reauthorization steps.
|