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.
@@ -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.