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
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
PA_MCP_TOKEN_PATH=
|
|
2
|
+
# Defaults to the same directory as PA_MCP_TOKEN_PATH.
|
|
3
|
+
PA_MCP_LOG_PATH=
|
|
4
|
+
|
|
5
|
+
GOOGLE_CLIENT_ID=
|
|
6
|
+
GOOGLE_CLIENT_SECRET=
|
|
7
|
+
# Optional legacy fallback; normally saved in the user token store.
|
|
8
|
+
GOOGLE_REFRESH_TOKEN=
|
|
9
|
+
GOOGLE_REDIRECT_URI=http://localhost:3000
|
|
10
|
+
|
|
11
|
+
LINKEDIN_CLIENT_ID=
|
|
12
|
+
LINKEDIN_CLIENT_SECRET=
|
|
13
|
+
# Optional legacy fallbacks; normally saved in the user token store.
|
|
14
|
+
LINKEDIN_ACCESS_TOKEN=
|
|
15
|
+
LINKEDIN_USER_URN=
|
|
16
|
+
LINKEDIN_REDIRECT_URI=http://localhost:3001/callback
|
|
17
|
+
LINKEDIN_API_VERSION=202609
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Contributions to Connected Workspace MCP are welcome. Bug reports, focused
|
|
4
|
+
feature proposals, documentation improvements, and pull requests are all
|
|
5
|
+
useful.
|
|
6
|
+
|
|
7
|
+
## Development Setup
|
|
8
|
+
|
|
9
|
+
1. Fork and clone the repository.
|
|
10
|
+
2. Install Node.js 20 or newer.
|
|
11
|
+
3. Install dependencies with `npm ci`.
|
|
12
|
+
4. Copy `.env.example` to `.env` only when live local testing is necessary.
|
|
13
|
+
5. Build with `npm run build`.
|
|
14
|
+
|
|
15
|
+
Do not commit `.env`, OAuth tokens, logs, email content, or LinkedIn post data.
|
|
16
|
+
Use mocked provider clients in automated tests.
|
|
17
|
+
|
|
18
|
+
## Making Changes
|
|
19
|
+
|
|
20
|
+
- Keep provider authorization under `src/auth/<provider>/`.
|
|
21
|
+
- Keep integration tools under `src/tools/<integration>/`.
|
|
22
|
+
- Keep `src/index.ts` limited to client construction and tool registration.
|
|
23
|
+
- Preserve the shared Google OAuth client used by Gmail and Calendar.
|
|
24
|
+
- Add or update focused Jest coverage for behavior changes.
|
|
25
|
+
- Avoid unrelated formatting or refactoring in the same pull request.
|
|
26
|
+
|
|
27
|
+
## Validate
|
|
28
|
+
|
|
29
|
+
Run the same checks used by pull-request automation:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
npm run format:check
|
|
33
|
+
npm test
|
|
34
|
+
npm run check
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Tests must not contact live providers or mutate real accounts.
|
|
39
|
+
|
|
40
|
+
## Pull Requests
|
|
41
|
+
|
|
42
|
+
Describe the user-visible behavior, note any provider permissions or API
|
|
43
|
+
products required, and include the validation performed. Link related issues
|
|
44
|
+
and call out breaking changes explicitly.
|
|
45
|
+
|
|
46
|
+
By contributing, you agree that your contributions are licensed under the
|
|
47
|
+
[MIT License](LICENSE).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deepak Kamboj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Connected Workspace MCP
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/connected-workspace-mcp)
|
|
4
|
+
[](https://github.com/deepakkamboj/connected-workspace-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://modelcontextprotocol.io/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
A TypeScript stdio MCP server for Gmail, Google Calendar, and LinkedIn. It gives
|
|
11
|
+
MCP-compatible assistants explicit tools for email, scheduling, professional
|
|
12
|
+
profile access, and social publishing.
|
|
13
|
+
|
|
14
|
+
Gmail and Calendar share one Google OAuth grant. LinkedIn uses a separate OAuth
|
|
15
|
+
grant. Both providers persist tokens outside the package directory so the
|
|
16
|
+
server can restart without repeated authorization.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Requires Node.js 20 or newer.
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
npm install --global connected-workspace-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Configure the provider credentials described in the auth guides, then run:
|
|
27
|
+
|
|
28
|
+
```powershell
|
|
29
|
+
connected-workspace-google-auth
|
|
30
|
+
connected-workspace-linkedin-auth
|
|
31
|
+
connected-workspace-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
With `npx`, keep credentials in a private file and pass only its path:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
npx -y connected-workspace-mcp --env-file "C:\Users\you\.connected-workspace-mcp\.env"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
LinkedIn is optional. The server can run with only Google configured.
|
|
41
|
+
|
|
42
|
+
## MCP Host Configuration
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"servers": {
|
|
47
|
+
"connected-workspace": {
|
|
48
|
+
"type": "stdio",
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": [
|
|
51
|
+
"-y",
|
|
52
|
+
"connected-workspace-mcp",
|
|
53
|
+
"--env-file",
|
|
54
|
+
"C:\\Users\\you\\.connected-workspace-mcp\\.env"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The process can instead inherit provider environment variables directly. Never
|
|
62
|
+
pass client secrets or tokens as command-line arguments.
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
- [Documentation index](docs/README.md)
|
|
67
|
+
- [Installation and MCP host setup](docs/installation.md)
|
|
68
|
+
- [Google OAuth setup](docs/google-auth.md)
|
|
69
|
+
- [LinkedIn OAuth setup](docs/linkedin-auth.md)
|
|
70
|
+
- [Configuration, token storage, and logs](docs/configuration.md)
|
|
71
|
+
- [Tool reference](docs/tools.md)
|
|
72
|
+
- [Authentication troubleshooting](docs/troubleshooting.md)
|
|
73
|
+
- [npm publishing guide](docs/publishing.md)
|
|
74
|
+
|
|
75
|
+
## Capabilities
|
|
76
|
+
|
|
77
|
+
Gmail tools search and read messages, send new messages, reply in threads, and
|
|
78
|
+
modify labels. Calendar tools list, create, update, and delete events, and query
|
|
79
|
+
free/busy periods. LinkedIn tools read the authenticated profile and posts,
|
|
80
|
+
inspect available engagement, publish text or image posts, and delete owned
|
|
81
|
+
posts.
|
|
82
|
+
|
|
83
|
+
Standard LinkedIn APIs do not support arbitrary edits to profile fields such as
|
|
84
|
+
headline, experience, or skills. Available endpoints depend on the products and
|
|
85
|
+
permissions approved for the LinkedIn app.
|
|
86
|
+
|
|
87
|
+
## Local Development
|
|
88
|
+
|
|
89
|
+
```powershell
|
|
90
|
+
npm install
|
|
91
|
+
Copy-Item .env.example .env
|
|
92
|
+
npm run auth:google
|
|
93
|
+
npm run auth:linkedin
|
|
94
|
+
npm run build
|
|
95
|
+
npm start
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Run the deterministic mocked test suite and project checks with:
|
|
99
|
+
|
|
100
|
+
```powershell
|
|
101
|
+
npm test
|
|
102
|
+
npm run check
|
|
103
|
+
npm run format:check
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tests never contact live providers or mutate real accounts.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for project
|
|
111
|
+
structure, security requirements, validation commands, and pull-request
|
|
112
|
+
guidance.
|
|
113
|
+
|
|
114
|
+
## Security
|
|
115
|
+
|
|
116
|
+
Never commit `.env`, token files, or logs. The JSON-lines logger excludes tool
|
|
117
|
+
arguments, message bodies, post content, and credentials. Configure your MCP
|
|
118
|
+
host to require confirmation before write tools send email, modify calendars,
|
|
119
|
+
or publish and delete LinkedIn posts.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
Licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import '../../config/bootstrap.js';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { google } from 'googleapis';
|
|
5
|
+
import { saveGoogleTokens, TOKEN_STORE_PATH } from '../token-store.js';
|
|
6
|
+
import { logger } from '../../logging/logger.js';
|
|
7
|
+
import { GOOGLE_SCOPES } from './index.js';
|
|
8
|
+
const clientId = process.env.GOOGLE_CLIENT_ID;
|
|
9
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
10
|
+
const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000';
|
|
11
|
+
if (!clientId || !clientSecret) {
|
|
12
|
+
throw new Error('Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env first.');
|
|
13
|
+
}
|
|
14
|
+
const callback = new URL(redirectUri);
|
|
15
|
+
const auth = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
|
16
|
+
const authorizationUrl = auth.generateAuthUrl({
|
|
17
|
+
access_type: 'offline',
|
|
18
|
+
include_granted_scopes: true,
|
|
19
|
+
prompt: 'consent',
|
|
20
|
+
scope: GOOGLE_SCOPES,
|
|
21
|
+
});
|
|
22
|
+
const server = createServer(async (request, response) => {
|
|
23
|
+
const requestUrl = new URL(request.url || '/', redirectUri);
|
|
24
|
+
if (requestUrl.pathname !== callback.pathname) {
|
|
25
|
+
response.writeHead(404).end('Not found');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const code = requestUrl.searchParams.get('code');
|
|
29
|
+
if (!code) {
|
|
30
|
+
response.writeHead(400).end('Google did not return an authorization code.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { tokens } = await auth.getToken(code);
|
|
35
|
+
if (!tokens.refresh_token) {
|
|
36
|
+
throw new Error('No refresh token returned. Revoke existing app access and authorize again.');
|
|
37
|
+
}
|
|
38
|
+
const grantedScopes = new Set((tokens.scope || '').split(' ').filter(Boolean));
|
|
39
|
+
const missingScopes = GOOGLE_SCOPES.filter((scope) => !grantedScopes.has(scope));
|
|
40
|
+
if (missingScopes.length > 0) {
|
|
41
|
+
throw new Error(`Authorization was incomplete. Approve these missing scopes: ${missingScopes.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
await saveGoogleTokens({
|
|
44
|
+
accessToken: tokens.access_token || undefined,
|
|
45
|
+
refreshToken: tokens.refresh_token,
|
|
46
|
+
expiryDate: tokens.expiry_date || undefined,
|
|
47
|
+
});
|
|
48
|
+
response
|
|
49
|
+
.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
50
|
+
.end('Google authorization complete. You can close this tab.');
|
|
51
|
+
await logger.info('Google authorization completed', {
|
|
52
|
+
tokenStore: TOKEN_STORE_PATH,
|
|
53
|
+
});
|
|
54
|
+
console.log(`Saved reusable Google credentials to ${TOKEN_STORE_PATH}.`);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
response
|
|
58
|
+
.writeHead(500)
|
|
59
|
+
.end('Google authorization failed. Check the terminal.');
|
|
60
|
+
await logger.error('Google authorization failed', error);
|
|
61
|
+
console.error(error);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
server.close();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
server.listen(Number(callback.port || 80), callback.hostname, () => {
|
|
69
|
+
console.log('Open this URL in your browser:');
|
|
70
|
+
console.log(authorizationUrl);
|
|
71
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { loadGoogleTokens } from '../token-store.js';
|
|
3
|
+
export const GOOGLE_SCOPES = [
|
|
4
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
5
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
6
|
+
'https://www.googleapis.com/auth/gmail.compose',
|
|
7
|
+
'https://www.googleapis.com/auth/calendar',
|
|
8
|
+
];
|
|
9
|
+
function requiredEnv(name) {
|
|
10
|
+
const value = process.env[name];
|
|
11
|
+
if (!value)
|
|
12
|
+
throw new Error(`${name} is required. Copy .env.example to .env and run npm run auth:google.`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
export function createGoogleAuth() {
|
|
16
|
+
const storedTokens = loadGoogleTokens();
|
|
17
|
+
const auth = new google.auth.OAuth2(requiredEnv('GOOGLE_CLIENT_ID'), requiredEnv('GOOGLE_CLIENT_SECRET'), process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000');
|
|
18
|
+
const refreshToken = storedTokens?.refreshToken || process.env.GOOGLE_REFRESH_TOKEN;
|
|
19
|
+
if (!refreshToken)
|
|
20
|
+
throw new Error('Google is not authorized. Run npm run auth:google once.');
|
|
21
|
+
auth.setCredentials({
|
|
22
|
+
access_token: storedTokens?.accessToken,
|
|
23
|
+
refresh_token: refreshToken,
|
|
24
|
+
expiry_date: storedTokens?.expiryDate,
|
|
25
|
+
});
|
|
26
|
+
return auth;
|
|
27
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import '../../config/bootstrap.js';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { saveLinkedInTokens, TOKEN_STORE_PATH } from '../token-store.js';
|
|
6
|
+
import { logger } from '../../logging/logger.js';
|
|
7
|
+
const clientId = process.env.LINKEDIN_CLIENT_ID;
|
|
8
|
+
const clientSecret = process.env.LINKEDIN_CLIENT_SECRET;
|
|
9
|
+
const redirectUri = process.env.LINKEDIN_REDIRECT_URI || 'http://localhost:3001/callback';
|
|
10
|
+
if (!clientId || !clientSecret) {
|
|
11
|
+
throw new Error('Set LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET in .env first.');
|
|
12
|
+
}
|
|
13
|
+
const state = randomBytes(24).toString('hex');
|
|
14
|
+
const callback = new URL(redirectUri);
|
|
15
|
+
const authorizationUrl = new URL('https://www.linkedin.com/oauth/v2/authorization');
|
|
16
|
+
authorizationUrl.search = new URLSearchParams({
|
|
17
|
+
response_type: 'code',
|
|
18
|
+
client_id: clientId,
|
|
19
|
+
redirect_uri: redirectUri,
|
|
20
|
+
state,
|
|
21
|
+
scope: 'openid profile email w_member_social',
|
|
22
|
+
}).toString();
|
|
23
|
+
const server = createServer(async (request, response) => {
|
|
24
|
+
const requestUrl = new URL(request.url || '/', redirectUri);
|
|
25
|
+
if (requestUrl.pathname !== callback.pathname) {
|
|
26
|
+
response.writeHead(404).end('Not found');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (requestUrl.searchParams.get('state') !== state) {
|
|
30
|
+
response.writeHead(400).end('Invalid OAuth state.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const code = requestUrl.searchParams.get('code');
|
|
34
|
+
if (!code) {
|
|
35
|
+
response
|
|
36
|
+
.writeHead(400)
|
|
37
|
+
.end('LinkedIn did not return an authorization code.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const tokenResponse = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
44
|
+
body: new URLSearchParams({
|
|
45
|
+
grant_type: 'authorization_code',
|
|
46
|
+
code,
|
|
47
|
+
redirect_uri: redirectUri,
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
client_secret: clientSecret,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
const tokens = (await tokenResponse.json());
|
|
53
|
+
if (!tokenResponse.ok || !tokens.access_token) {
|
|
54
|
+
throw new Error(tokens.error_description || 'LinkedIn token exchange failed.');
|
|
55
|
+
}
|
|
56
|
+
const profileResponse = await fetch('https://api.linkedin.com/v2/userinfo', {
|
|
57
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
58
|
+
});
|
|
59
|
+
const profile = (await profileResponse.json());
|
|
60
|
+
if (!profileResponse.ok || !profile.sub)
|
|
61
|
+
throw new Error('LinkedIn profile lookup failed.');
|
|
62
|
+
await saveLinkedInTokens({
|
|
63
|
+
accessToken: tokens.access_token,
|
|
64
|
+
userUrn: `urn:li:person:${profile.sub}`,
|
|
65
|
+
refreshToken: tokens.refresh_token,
|
|
66
|
+
expiresAt: tokens.expires_in
|
|
67
|
+
? Date.now() + tokens.expires_in * 1000
|
|
68
|
+
: undefined,
|
|
69
|
+
});
|
|
70
|
+
response
|
|
71
|
+
.writeHead(200, { 'Content-Type': 'text/plain' })
|
|
72
|
+
.end('LinkedIn authorization complete. You can close this tab.');
|
|
73
|
+
await logger.info('LinkedIn authorization completed', {
|
|
74
|
+
tokenStore: TOKEN_STORE_PATH,
|
|
75
|
+
});
|
|
76
|
+
console.log(`Saved reusable LinkedIn credentials to ${TOKEN_STORE_PATH}.`);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
response
|
|
80
|
+
.writeHead(500)
|
|
81
|
+
.end('LinkedIn authorization failed. Check the terminal.');
|
|
82
|
+
await logger.error('LinkedIn authorization failed', error);
|
|
83
|
+
console.error(error);
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
server.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
server.listen(Number(callback.port || 80), callback.hostname, () => {
|
|
91
|
+
console.log('Open this URL in your browser:');
|
|
92
|
+
console.log(authorizationUrl.toString());
|
|
93
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { loadLinkedInTokens } from '../token-store.js';
|
|
2
|
+
export function createLinkedInAuth() {
|
|
3
|
+
const storedTokens = loadLinkedInTokens();
|
|
4
|
+
const accessToken = storedTokens?.accessToken || process.env.LINKEDIN_ACCESS_TOKEN;
|
|
5
|
+
if (!accessToken)
|
|
6
|
+
throw new Error('LinkedIn is not authorized. Run npm run auth:linkedin once.');
|
|
7
|
+
const userUrn = storedTokens?.userUrn || process.env.LINKEDIN_USER_URN;
|
|
8
|
+
if (!userUrn)
|
|
9
|
+
throw new Error('LinkedIn user URN is missing. Run npm run auth:linkedin once.');
|
|
10
|
+
return {
|
|
11
|
+
accessToken,
|
|
12
|
+
userUrn,
|
|
13
|
+
apiVersion: process.env.LINKEDIN_API_VERSION || '202609',
|
|
14
|
+
expiresAt: storedTokens?.expiresAt,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
export const TOKEN_STORE_PATH = process.env.PA_MCP_TOKEN_PATH || join(homedir(), '.pa-mcp', 'tokens.json');
|
|
6
|
+
function readStore() {
|
|
7
|
+
if (!existsSync(TOKEN_STORE_PATH))
|
|
8
|
+
return {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(TOKEN_STORE_PATH, 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
throw new Error(`Could not read token store at ${TOKEN_STORE_PATH}: ${String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function writeStore(store) {
|
|
17
|
+
await mkdir(dirname(TOKEN_STORE_PATH), { recursive: true });
|
|
18
|
+
const temporaryPath = `${TOKEN_STORE_PATH}.tmp`;
|
|
19
|
+
await writeFile(temporaryPath, `${JSON.stringify(store, null, 2)}\n`, {
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
mode: 0o600,
|
|
22
|
+
});
|
|
23
|
+
await rename(temporaryPath, TOKEN_STORE_PATH);
|
|
24
|
+
}
|
|
25
|
+
export function loadGoogleTokens() {
|
|
26
|
+
return readStore().google;
|
|
27
|
+
}
|
|
28
|
+
export async function saveGoogleTokens(tokens) {
|
|
29
|
+
await writeStore({ ...readStore(), google: tokens });
|
|
30
|
+
}
|
|
31
|
+
export function loadLinkedInTokens() {
|
|
32
|
+
return readStore().linkedin;
|
|
33
|
+
}
|
|
34
|
+
export async function saveLinkedInTokens(tokens) {
|
|
35
|
+
await writeStore({ ...readStore(), linkedin: tokens });
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function getEnvFilePath(args) {
|
|
2
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3
|
+
const argument = args[index];
|
|
4
|
+
if (argument === '--env-file') {
|
|
5
|
+
const path = args[index + 1];
|
|
6
|
+
if (!path || path.startsWith('--')) {
|
|
7
|
+
throw new Error('--env-file requires a path.');
|
|
8
|
+
}
|
|
9
|
+
return path;
|
|
10
|
+
}
|
|
11
|
+
if (argument.startsWith('--env-file=')) {
|
|
12
|
+
const path = argument.slice('--env-file='.length);
|
|
13
|
+
if (!path)
|
|
14
|
+
throw new Error('--env-file requires a path.');
|
|
15
|
+
return path;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { config } from 'dotenv';
|
|
3
|
+
import { getEnvFilePath } from './arguments.js';
|
|
4
|
+
export function loadEnvironment(args) {
|
|
5
|
+
const envFileArgument = getEnvFilePath(args);
|
|
6
|
+
const envFilePath = envFileArgument ? resolve(envFileArgument) : undefined;
|
|
7
|
+
const result = config(envFilePath ? { path: envFilePath, quiet: true } : { quiet: true });
|
|
8
|
+
if (envFilePath && result.error) {
|
|
9
|
+
throw new Error(`Unable to load environment file: ${envFilePath}`, {
|
|
10
|
+
cause: result.error,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return envFilePath;
|
|
14
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import './config/bootstrap.js';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
5
|
+
import { google } from 'googleapis';
|
|
6
|
+
import { createGoogleAuth } from './auth/google/index.js';
|
|
7
|
+
import { registerCalendarTools } from './tools/calendar/index.js';
|
|
8
|
+
import { registerGmailTools } from './tools/gmail/index.js';
|
|
9
|
+
import { registerLinkedInTools } from './tools/linkedin/index.js';
|
|
10
|
+
import { logger, LOG_FILE_PATH } from './logging/logger.js';
|
|
11
|
+
import { getPackageMetadata } from './utils/common.js';
|
|
12
|
+
const auth = createGoogleAuth();
|
|
13
|
+
const gmail = google.gmail({ version: 'v1', auth });
|
|
14
|
+
const calendar = google.calendar({ version: 'v3', auth });
|
|
15
|
+
const packageMetadata = getPackageMetadata();
|
|
16
|
+
const server = new McpServer(packageMetadata);
|
|
17
|
+
try {
|
|
18
|
+
registerGmailTools(server, gmail);
|
|
19
|
+
registerCalendarTools(server, calendar);
|
|
20
|
+
registerLinkedInTools(server);
|
|
21
|
+
await logger.info('All MCP tools registered');
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
await logger.error('Failed to register MCP tools', error);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
let shuttingDown = false;
|
|
28
|
+
async function cleanup(signal) {
|
|
29
|
+
if (shuttingDown)
|
|
30
|
+
return;
|
|
31
|
+
shuttingDown = true;
|
|
32
|
+
try {
|
|
33
|
+
await logger.info('MCP server shutting down', { signal });
|
|
34
|
+
await server.close();
|
|
35
|
+
await logger.info('MCP server shutdown completed');
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
await logger.error('MCP server shutdown failed', error, { signal });
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function main() {
|
|
44
|
+
await logger.info('MCP server starting', {
|
|
45
|
+
logFile: LOG_FILE_PATH,
|
|
46
|
+
name: packageMetadata.name,
|
|
47
|
+
version: packageMetadata.version,
|
|
48
|
+
});
|
|
49
|
+
await server.connect(new StdioServerTransport());
|
|
50
|
+
await logger.info('MCP server connected');
|
|
51
|
+
}
|
|
52
|
+
main().catch((error) => {
|
|
53
|
+
void logger
|
|
54
|
+
.error('MCP server failed to start', error)
|
|
55
|
+
.finally(() => process.exit(1));
|
|
56
|
+
});
|
|
57
|
+
process.on('uncaughtException', (error) => {
|
|
58
|
+
void logger.error('Uncaught exception', error).finally(() => process.exit(1));
|
|
59
|
+
});
|
|
60
|
+
process.on('unhandledRejection', (error) => {
|
|
61
|
+
void logger.error('Unhandled rejection', error);
|
|
62
|
+
});
|
|
63
|
+
process.on('SIGTERM', () => void cleanup('SIGTERM'));
|
|
64
|
+
process.on('SIGINT', () => void cleanup('SIGINT'));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { appendFile, mkdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { TOKEN_STORE_PATH } from '../auth/token-store.js';
|
|
5
|
+
export const LOG_FILE_PATH = process.env.PA_MCP_LOG_PATH || join(dirname(TOKEN_STORE_PATH), 'pa-mcp.log');
|
|
6
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export function redact(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
|
|
10
|
+
.replace(/(access_token|refresh_token|client_secret|authorization)["'=:\s]+[^\s,"'}]+/gi, '$1=[REDACTED]');
|
|
11
|
+
}
|
|
12
|
+
export function errorDetails(error) {
|
|
13
|
+
if (error instanceof Error) {
|
|
14
|
+
return {
|
|
15
|
+
errorName: error.name,
|
|
16
|
+
errorMessage: redact(error.message),
|
|
17
|
+
stack: error.stack ? redact(error.stack) : undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return { errorMessage: redact(String(error)) };
|
|
21
|
+
}
|
|
22
|
+
async function rotateIfNeeded() {
|
|
23
|
+
if (!existsSync(LOG_FILE_PATH) ||
|
|
24
|
+
statSync(LOG_FILE_PATH).size < MAX_LOG_BYTES)
|
|
25
|
+
return;
|
|
26
|
+
const rotatedPath = `${LOG_FILE_PATH}.1`;
|
|
27
|
+
try {
|
|
28
|
+
await rm(rotatedPath, { force: true });
|
|
29
|
+
await rename(LOG_FILE_PATH, rotatedPath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A concurrent writer may already have rotated the file.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function write(level, message, details) {
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(dirname(LOG_FILE_PATH), { recursive: true });
|
|
38
|
+
await rotateIfNeeded();
|
|
39
|
+
const entry = JSON.stringify({
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
level,
|
|
42
|
+
message: redact(message),
|
|
43
|
+
processId: process.pid,
|
|
44
|
+
...details,
|
|
45
|
+
});
|
|
46
|
+
await appendFile(LOG_FILE_PATH, `${entry}\n`, {
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
mode: 0o600,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Logging must never interrupt MCP protocol handling.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const logger = {
|
|
56
|
+
debug: (message, details) => write('debug', message, details),
|
|
57
|
+
info: (message, details) => write('info', message, details),
|
|
58
|
+
warn: (message, details) => write('warn', message, details),
|
|
59
|
+
error: (message, error, details) => write('error', message, {
|
|
60
|
+
...(error === undefined ? {} : errorDetails(error)),
|
|
61
|
+
...details,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
export function publicErrorMessage(error) {
|
|
65
|
+
return redact(error instanceof Error ? error.message : 'Unexpected error');
|
|
66
|
+
}
|