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 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
@@ -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
+ [![npm version](https://img.shields.io/npm/v/connected-workspace-mcp)](https://www.npmjs.com/package/connected-workspace-mcp)
4
+ [![CI](https://github.com/deepakkamboj/connected-workspace-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/deepakkamboj/connected-workspace-mcp/actions/workflows/ci.yml)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
7
+ [![MCP](https://img.shields.io/badge/Model_Context_Protocol-v2-0A7EA4)](https://modelcontextprotocol.io/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](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,2 @@
1
+ import { loadEnvironment } from './environment.js';
2
+ loadEnvironment(process.argv.slice(2));
@@ -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
+ }