@ldraney/mcp-linkedin 0.2.3 → 0.3.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/CLAUDE.md +3 -2
- package/README.md +61 -94
- package/SECURITY.md +65 -0
- package/__tests__/auth/local-server.test.js +177 -0
- package/__tests__/auth/token-storage.test.js +141 -0
- package/__tests__/tools.test.js +25 -7
- package/docs/index.html +355 -0
- package/docs/logo-300x300.png +0 -0
- package/docs/logo-draft-1.webp +0 -0
- package/docs/og-image.png +0 -0
- package/docs/privacy.html +161 -0
- package/manifest.json +1 -1
- package/oauth-relay/fly.toml +1 -1
- package/oauth-relay/index.js +87 -12
- package/package.json +2 -1
- package/src/auth/local-server.js +220 -0
- package/src/auth/token-storage.js +71 -0
- package/src/index.js +32 -4
- package/src/linkedin-api.js +1 -1
- package/src/tools.js +46 -16
- package/.claude/settings.local.json +0 -28
package/CLAUDE.md
CHANGED
|
@@ -7,7 +7,7 @@ MCP server for LinkedIn post management via Claude Desktop.
|
|
|
7
7
|
```
|
|
8
8
|
src/
|
|
9
9
|
index.js # MCP server entry point (stdio transport)
|
|
10
|
-
tools.js #
|
|
10
|
+
tools.js # 21 tool implementations
|
|
11
11
|
linkedin-api.js # LinkedIn REST API client
|
|
12
12
|
schemas.js # Zod validation schemas
|
|
13
13
|
types.js # JSDoc type definitions
|
|
@@ -33,6 +33,7 @@ __tests__/ # Jest test suite (118 tests)
|
|
|
33
33
|
| `linkedin_add_comment` | Add comment to a post |
|
|
34
34
|
| `linkedin_add_reaction` | React to a post (LIKE, PRAISE, EMPATHY, etc.) |
|
|
35
35
|
| `linkedin_get_auth_url` | Start OAuth flow |
|
|
36
|
+
| `linkedin_save_credentials` | Save credentials from OAuth callback |
|
|
36
37
|
| `linkedin_exchange_code` | Complete OAuth |
|
|
37
38
|
| `linkedin_refresh_token` | Refresh expired access token |
|
|
38
39
|
| `linkedin_get_user_info` | Get profile info |
|
|
@@ -73,7 +74,7 @@ Required in `.env`:
|
|
|
73
74
|
```bash
|
|
74
75
|
npm start # Start MCP server
|
|
75
76
|
npm scheduler # Start scheduler daemon (runs every minute)
|
|
76
|
-
npm test # Run tests (
|
|
77
|
+
npm test # Run tests (118 passing)
|
|
77
78
|
```
|
|
78
79
|
|
|
79
80
|
## Roadmap
|
package/README.md
CHANGED
|
@@ -1,115 +1,82 @@
|
|
|
1
1
|
# mcp-linkedin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Post to LinkedIn from Claude Desktop.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Install in 30 seconds. Authenticate once. Post forever.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
Download from [latest release](https://github.com/intelligent-staffing-systems/mcp-linkedin/releases/latest) and open `mcp-linkedin.mcpb` with Claude Desktop.
|
|
7
|
+
## Quick Start
|
|
9
8
|
|
|
10
|
-
###
|
|
9
|
+
### Option A: One-Click Install (Recommended)
|
|
10
|
+
1. **Download** [mcp-linkedin.mcpb](https://github.com/ldraney/mcp-linkedin/releases/latest/download/mcp-linkedin.mcpb)
|
|
11
|
+
2. **Open** with Claude Desktop
|
|
12
|
+
3. **Authorize** when prompted (one-time LinkedIn OAuth)
|
|
13
|
+
|
|
14
|
+
### Option B: Command Line
|
|
11
15
|
```bash
|
|
12
16
|
npx @ldraney/mcp-linkedin
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
```json
|
|
17
|
-
{
|
|
18
|
-
"mcpServers": {
|
|
19
|
-
"linkedin": {
|
|
20
|
-
"command": "npx",
|
|
21
|
-
"args": ["@ldraney/mcp-linkedin"],
|
|
22
|
-
"env": {
|
|
23
|
-
"LINKEDIN_CLIENT_ID": "your-client-id",
|
|
24
|
-
"LINKEDIN_CLIENT_SECRET": "your-client-secret",
|
|
25
|
-
"LINKEDIN_REDIRECT_URI": "https://localhost:8888/callback",
|
|
26
|
-
"LINKEDIN_PERSON_ID": "your-person-id",
|
|
27
|
-
"LINKEDIN_ACCESS_TOKEN": "your-access-token"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
```
|
|
19
|
+
## What You Can Do
|
|
33
20
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
| Tool | Description |
|
|
62
|
-
|------|-------------|
|
|
63
|
-
| `linkedin_schedule_post` | Schedule for future publication |
|
|
64
|
-
| `linkedin_list_scheduled_posts` | List by status |
|
|
65
|
-
| `linkedin_cancel_scheduled_post` | Cancel pending post |
|
|
66
|
-
| `linkedin_get_scheduled_post` | Get scheduled post details |
|
|
67
|
-
|
|
68
|
-
### Authentication
|
|
69
|
-
| Tool | Description |
|
|
70
|
-
|------|-------------|
|
|
71
|
-
| `linkedin_get_auth_url` | Start OAuth flow |
|
|
72
|
-
| `linkedin_exchange_code` | Complete OAuth |
|
|
73
|
-
| `linkedin_refresh_token` | Refresh expired token |
|
|
74
|
-
| `linkedin_get_user_info` | Get profile info |
|
|
75
|
-
|
|
76
|
-
## Setup
|
|
77
|
-
|
|
78
|
-
### Option 1: Quick Setup (Recommended)
|
|
79
|
-
1. Install the extension
|
|
80
|
-
2. Ask Claude: "Help me set up LinkedIn authentication"
|
|
81
|
-
3. Follow the guided OAuth flow
|
|
82
|
-
|
|
83
|
-
### Option 2: Manual Setup
|
|
84
|
-
1. Create a LinkedIn Developer App at https://www.linkedin.com/developers/apps
|
|
85
|
-
2. Add products: "Sign In with LinkedIn using OpenID Connect" + "Share on LinkedIn"
|
|
86
|
-
3. Configure redirect URI: `https://localhost:8888/callback`
|
|
87
|
-
4. Set environment variables (see Installation above)
|
|
88
|
-
|
|
89
|
-
## Running the Scheduler
|
|
90
|
-
|
|
91
|
-
For scheduled posts to publish automatically:
|
|
92
|
-
```bash
|
|
93
|
-
npm run scheduler
|
|
21
|
+
Ask Claude things like:
|
|
22
|
+
- *"Post about my new GitHub project with a link"*
|
|
23
|
+
- *"Create a poll asking my network about AI trends"*
|
|
24
|
+
- *"Schedule a post for tomorrow at 9am"*
|
|
25
|
+
- *"Upload this PDF and share it with my network"*
|
|
26
|
+
- *"Add a comment to my latest post"*
|
|
27
|
+
|
|
28
|
+
## 21 Tools Available
|
|
29
|
+
|
|
30
|
+
| Category | Tools |
|
|
31
|
+
|----------|-------|
|
|
32
|
+
| **Posting** | Text, links, images, videos, documents, polls |
|
|
33
|
+
| **Multi-media** | Up to 20 images per post |
|
|
34
|
+
| **Scheduling** | Schedule posts for future publication |
|
|
35
|
+
| **Engagement** | Comment and react to posts |
|
|
36
|
+
| **Management** | Edit, delete, list your posts |
|
|
37
|
+
|
|
38
|
+
## How It Works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
You install mcp-linkedin (npm or .mcpb)
|
|
42
|
+
│
|
|
43
|
+
└─► First use triggers OAuth
|
|
44
|
+
│
|
|
45
|
+
└─► Tokens stored locally on your machine
|
|
46
|
+
│
|
|
47
|
+
└─► Claude posts to LinkedIn for you
|
|
94
48
|
```
|
|
95
49
|
|
|
96
|
-
|
|
50
|
+
Your credentials stay on your machine. The OAuth relay only handles the initial handshake.
|
|
97
51
|
|
|
98
|
-
##
|
|
52
|
+
## Privacy
|
|
53
|
+
|
|
54
|
+
- Your LinkedIn access token is stored locally on your machine
|
|
55
|
+
- We never store or access your LinkedIn credentials
|
|
56
|
+
- All API calls go directly from your machine to LinkedIn
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- [Claude Desktop](https://claude.ai/download)
|
|
61
|
+
- LinkedIn account
|
|
62
|
+
|
|
63
|
+
## Feedback
|
|
64
|
+
|
|
65
|
+
Found a bug? Have a feature request?
|
|
66
|
+
[Open an issue](https://github.com/ldraney/mcp-linkedin/issues/new)
|
|
67
|
+
|
|
68
|
+
For security vulnerabilities, see [SECURITY.md](./SECURITY.md).
|
|
69
|
+
|
|
70
|
+
## For Developers
|
|
99
71
|
|
|
100
72
|
```bash
|
|
73
|
+
git clone https://github.com/ldraney/mcp-linkedin.git
|
|
74
|
+
cd mcp-linkedin
|
|
101
75
|
npm install
|
|
102
|
-
npm test
|
|
103
|
-
npm run test:coverage # Coverage report
|
|
104
|
-
npm start # Start MCP server
|
|
105
|
-
npm run scheduler # Start scheduler daemon
|
|
76
|
+
npm test # 118 tests
|
|
106
77
|
```
|
|
107
78
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- [CLAUDE.md](./CLAUDE.md) - Project structure and API details
|
|
111
|
-
- [MCP_SETUP.md](./MCP_SETUP.md) - Claude Desktop configuration
|
|
112
|
-
- [LINKEDIN_API_REFERENCE.md](./LINKEDIN_API_REFERENCE.md) - API documentation
|
|
79
|
+
See [CLAUDE.md](./CLAUDE.md) for architecture and API details.
|
|
113
80
|
|
|
114
81
|
## License
|
|
115
82
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
If you discover a security vulnerability in mcp-linkedin, please report it responsibly:
|
|
6
|
+
|
|
7
|
+
1. **Do NOT open a public issue** for security vulnerabilities
|
|
8
|
+
2. **Email:** Open a [private security advisory](https://github.com/ldraney/mcp-linkedin/security/advisories/new) on GitHub
|
|
9
|
+
3. **Include:** Description of the vulnerability, steps to reproduce, and potential impact
|
|
10
|
+
|
|
11
|
+
You should receive a response within 48 hours. We'll work with you to understand the issue and coordinate a fix before any public disclosure.
|
|
12
|
+
|
|
13
|
+
## Security Model
|
|
14
|
+
|
|
15
|
+
### Credential Storage
|
|
16
|
+
|
|
17
|
+
- OAuth tokens are stored in your **operating system's secure keychain** (macOS Keychain, Windows Credential Manager, Linux Secret Service) via [@napi-rs/keyring](https://github.com/nicedoc/keyring)
|
|
18
|
+
- Tokens are **never** stored in plain text files, environment variables are only used as a fallback for development
|
|
19
|
+
- The OAuth relay server (`fly.io`) only handles the initial handshake redirect -- it never sees or stores your access token
|
|
20
|
+
|
|
21
|
+
### Data Flow
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Your Machine External
|
|
25
|
+
┌─────────────────┐ ┌──────────────┐
|
|
26
|
+
│ Claude Desktop │ │ │
|
|
27
|
+
│ ├─ mcp-linkedin│──────────►│ LinkedIn API │
|
|
28
|
+
│ └─ OS Keychain│ │ │
|
|
29
|
+
└─────────────────┘ └──────────────┘
|
|
30
|
+
│
|
|
31
|
+
│ (OAuth only)
|
|
32
|
+
▼
|
|
33
|
+
┌─────────────────┐
|
|
34
|
+
│ OAuth Relay │
|
|
35
|
+
│ (fly.io) │
|
|
36
|
+
│ Redirect only │
|
|
37
|
+
└─────────────────┘
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- All LinkedIn API calls go **directly** from your machine to LinkedIn
|
|
41
|
+
- No telemetry, analytics, or data collection
|
|
42
|
+
- Scheduled posts are stored in a **local** SQLite database on your machine
|
|
43
|
+
|
|
44
|
+
### OAuth Security
|
|
45
|
+
|
|
46
|
+
- CSRF protection via cryptographic nonces on every OAuth flow
|
|
47
|
+
- Tokens are captured by a local callback server and stored directly in your keychain
|
|
48
|
+
- The `w_member_social` scope is the only permission requested (post and interact on your behalf)
|
|
49
|
+
|
|
50
|
+
### What We Don't Do
|
|
51
|
+
|
|
52
|
+
- We never store your credentials on any remote server
|
|
53
|
+
- We never log or transmit your LinkedIn data
|
|
54
|
+
- We never access your LinkedIn connections, messages, or profile data beyond what you explicitly post
|
|
55
|
+
|
|
56
|
+
## Supported Versions
|
|
57
|
+
|
|
58
|
+
| Version | Supported |
|
|
59
|
+
|---------|-----------|
|
|
60
|
+
| 0.2.x | Yes |
|
|
61
|
+
| < 0.2 | No |
|
|
62
|
+
|
|
63
|
+
## Dependencies
|
|
64
|
+
|
|
65
|
+
We keep dependencies minimal to reduce attack surface. Run `npm audit` to check for known vulnerabilities in dependencies.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for local callback server
|
|
3
|
+
* Tests the OAuth callback server functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
// We need to test the actual module, not mock it
|
|
10
|
+
const {
|
|
11
|
+
startCallbackServer,
|
|
12
|
+
generateNonce,
|
|
13
|
+
findAvailablePort
|
|
14
|
+
} = require('../../src/auth/local-server');
|
|
15
|
+
|
|
16
|
+
describe('Local Callback Server', () => {
|
|
17
|
+
describe('generateNonce', () => {
|
|
18
|
+
it('should generate a base64url-encoded string', () => {
|
|
19
|
+
const nonce = generateNonce();
|
|
20
|
+
|
|
21
|
+
expect(typeof nonce).toBe('string');
|
|
22
|
+
expect(nonce.length).toBeGreaterThan(20);
|
|
23
|
+
// Base64url should not contain + or /
|
|
24
|
+
expect(nonce).not.toMatch(/[+\/]/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should generate unique nonces', () => {
|
|
28
|
+
const nonces = new Set();
|
|
29
|
+
for (let i = 0; i < 100; i++) {
|
|
30
|
+
nonces.add(generateNonce());
|
|
31
|
+
}
|
|
32
|
+
expect(nonces.size).toBe(100);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('findAvailablePort', () => {
|
|
37
|
+
it('should find a port in the expected range', async () => {
|
|
38
|
+
const port = await findAvailablePort();
|
|
39
|
+
|
|
40
|
+
expect(port).toBeGreaterThanOrEqual(8880);
|
|
41
|
+
expect(port).toBeLessThanOrEqual(8899);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('startCallbackServer', () => {
|
|
46
|
+
it('should return port, nonce, and waitForCallback function', async () => {
|
|
47
|
+
const serverInfo = await startCallbackServer();
|
|
48
|
+
|
|
49
|
+
expect(serverInfo).toHaveProperty('port');
|
|
50
|
+
expect(serverInfo).toHaveProperty('nonce');
|
|
51
|
+
expect(serverInfo).toHaveProperty('waitForCallback');
|
|
52
|
+
|
|
53
|
+
expect(typeof serverInfo.port).toBe('number');
|
|
54
|
+
expect(typeof serverInfo.nonce).toBe('string');
|
|
55
|
+
expect(typeof serverInfo.waitForCallback).toBe('function');
|
|
56
|
+
|
|
57
|
+
expect(serverInfo.port).toBeGreaterThanOrEqual(8880);
|
|
58
|
+
expect(serverInfo.port).toBeLessThanOrEqual(8899);
|
|
59
|
+
|
|
60
|
+
// Don't call waitForCallback - that would start the server listening
|
|
61
|
+
// Just verify the shape is correct
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should receive credentials on successful callback', async () => {
|
|
65
|
+
const { port, nonce, waitForCallback } = await startCallbackServer();
|
|
66
|
+
|
|
67
|
+
// Start waiting for callback
|
|
68
|
+
const callbackPromise = waitForCallback();
|
|
69
|
+
|
|
70
|
+
// Simulate OAuth relay callback
|
|
71
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
72
|
+
`nonce=${nonce}&` +
|
|
73
|
+
`access_token=test-access-token&` +
|
|
74
|
+
`person_id=test-person-id&` +
|
|
75
|
+
`expires_in=5184000`;
|
|
76
|
+
|
|
77
|
+
// Make HTTP request to callback endpoint
|
|
78
|
+
await new Promise((resolve, reject) => {
|
|
79
|
+
const url = new URL(callbackUrl);
|
|
80
|
+
const req = http.request({
|
|
81
|
+
hostname: '127.0.0.1',
|
|
82
|
+
port: port,
|
|
83
|
+
path: url.pathname + url.search,
|
|
84
|
+
method: 'GET'
|
|
85
|
+
}, (res) => {
|
|
86
|
+
expect(res.statusCode).toBe(200);
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Wait for credentials
|
|
94
|
+
const credentials = await callbackPromise;
|
|
95
|
+
|
|
96
|
+
expect(credentials).toEqual({
|
|
97
|
+
accessToken: 'test-access-token',
|
|
98
|
+
personId: 'test-person-id',
|
|
99
|
+
refreshToken: null,
|
|
100
|
+
expiresAt: expect.any(Number)
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should reject on nonce mismatch', async () => {
|
|
105
|
+
const { port, waitForCallback } = await startCallbackServer();
|
|
106
|
+
|
|
107
|
+
// Start waiting FIRST and attach catch handler immediately
|
|
108
|
+
const callbackPromise = waitForCallback();
|
|
109
|
+
let rejection = null;
|
|
110
|
+
callbackPromise.catch(err => { rejection = err; });
|
|
111
|
+
|
|
112
|
+
// Callback with wrong nonce
|
|
113
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
114
|
+
`nonce=wrong-nonce&` +
|
|
115
|
+
`access_token=test-token&` +
|
|
116
|
+
`person_id=test-person`;
|
|
117
|
+
|
|
118
|
+
// Make the request
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
const url = new URL(callbackUrl);
|
|
121
|
+
const req = http.request({
|
|
122
|
+
hostname: '127.0.0.1',
|
|
123
|
+
port: port,
|
|
124
|
+
path: url.pathname + url.search,
|
|
125
|
+
method: 'GET'
|
|
126
|
+
}, (res) => {
|
|
127
|
+
expect(res.statusCode).toBe(400);
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
req.on('error', resolve);
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Wait a tick for the rejection to be captured
|
|
135
|
+
await new Promise(r => setTimeout(r, 10));
|
|
136
|
+
|
|
137
|
+
expect(rejection).not.toBeNull();
|
|
138
|
+
expect(rejection.message).toContain('Nonce mismatch');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should reject on OAuth error', async () => {
|
|
142
|
+
const { port, nonce, waitForCallback } = await startCallbackServer();
|
|
143
|
+
|
|
144
|
+
// Start waiting FIRST and attach catch handler immediately
|
|
145
|
+
const callbackPromise = waitForCallback();
|
|
146
|
+
let rejection = null;
|
|
147
|
+
callbackPromise.catch(err => { rejection = err; });
|
|
148
|
+
|
|
149
|
+
// Callback with error
|
|
150
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback?` +
|
|
151
|
+
`nonce=${nonce}&` +
|
|
152
|
+
`error=access_denied&` +
|
|
153
|
+
`error_description=User+denied+access`;
|
|
154
|
+
|
|
155
|
+
await new Promise((resolve) => {
|
|
156
|
+
const url = new URL(callbackUrl);
|
|
157
|
+
const req = http.request({
|
|
158
|
+
hostname: '127.0.0.1',
|
|
159
|
+
port: port,
|
|
160
|
+
path: url.pathname + url.search,
|
|
161
|
+
method: 'GET'
|
|
162
|
+
}, (res) => {
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
166
|
+
req.on('error', resolve);
|
|
167
|
+
req.end();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Wait a tick for the rejection to be captured
|
|
171
|
+
await new Promise(r => setTimeout(r, 10));
|
|
172
|
+
|
|
173
|
+
expect(rejection).not.toBeNull();
|
|
174
|
+
expect(rejection.message).toContain('OAuth error');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for token storage module
|
|
3
|
+
* Tests the OS keychain credential storage functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Mock @napi-rs/keyring before requiring the module
|
|
7
|
+
const mockSetPassword = jest.fn();
|
|
8
|
+
const mockGetPassword = jest.fn();
|
|
9
|
+
const mockDeletePassword = jest.fn();
|
|
10
|
+
|
|
11
|
+
jest.mock('@napi-rs/keyring', () => ({
|
|
12
|
+
Entry: jest.fn().mockImplementation(() => ({
|
|
13
|
+
setPassword: mockSetPassword,
|
|
14
|
+
getPassword: mockGetPassword,
|
|
15
|
+
deletePassword: mockDeletePassword
|
|
16
|
+
}))
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { Entry } = require('@napi-rs/keyring');
|
|
20
|
+
const {
|
|
21
|
+
storeCredentials,
|
|
22
|
+
getCredentials,
|
|
23
|
+
deleteCredentials,
|
|
24
|
+
hasCredentials
|
|
25
|
+
} = require('../../src/auth/token-storage');
|
|
26
|
+
|
|
27
|
+
describe('Token Storage Module', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('storeCredentials', () => {
|
|
33
|
+
it('should store credentials as JSON in keychain', () => {
|
|
34
|
+
const credentials = {
|
|
35
|
+
accessToken: 'test-token-123',
|
|
36
|
+
personId: 'test-person-456',
|
|
37
|
+
refreshToken: 'refresh-789',
|
|
38
|
+
expiresAt: Date.now() + 3600000
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
storeCredentials(credentials);
|
|
42
|
+
|
|
43
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
44
|
+
expect(mockSetPassword).toHaveBeenCalledWith(JSON.stringify(credentials));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should store minimal credentials', () => {
|
|
48
|
+
const credentials = {
|
|
49
|
+
accessToken: 'test-token',
|
|
50
|
+
personId: 'test-person'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
storeCredentials(credentials);
|
|
54
|
+
|
|
55
|
+
expect(mockSetPassword).toHaveBeenCalledWith(JSON.stringify(credentials));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('getCredentials', () => {
|
|
60
|
+
it('should retrieve and parse credentials from keychain', () => {
|
|
61
|
+
const storedData = {
|
|
62
|
+
accessToken: 'stored-token',
|
|
63
|
+
personId: 'stored-person',
|
|
64
|
+
refreshToken: null,
|
|
65
|
+
expiresAt: null
|
|
66
|
+
};
|
|
67
|
+
mockGetPassword.mockReturnValue(JSON.stringify(storedData));
|
|
68
|
+
|
|
69
|
+
const result = getCredentials();
|
|
70
|
+
|
|
71
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
72
|
+
expect(result).toEqual(storedData);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null when no credentials stored', () => {
|
|
76
|
+
mockGetPassword.mockReturnValue(null);
|
|
77
|
+
|
|
78
|
+
const result = getCredentials();
|
|
79
|
+
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should return null on keychain access error', () => {
|
|
84
|
+
mockGetPassword.mockImplementation(() => {
|
|
85
|
+
throw new Error('Access denied');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = getCredentials();
|
|
89
|
+
|
|
90
|
+
expect(result).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return null for empty string', () => {
|
|
94
|
+
mockGetPassword.mockReturnValue('');
|
|
95
|
+
|
|
96
|
+
const result = getCredentials();
|
|
97
|
+
|
|
98
|
+
expect(result).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('deleteCredentials', () => {
|
|
103
|
+
it('should delete credentials from keychain', () => {
|
|
104
|
+
mockDeletePassword.mockReturnValue(undefined);
|
|
105
|
+
|
|
106
|
+
const result = deleteCredentials();
|
|
107
|
+
|
|
108
|
+
expect(Entry).toHaveBeenCalledWith('mcp-linkedin', 'oauth-credentials');
|
|
109
|
+
expect(mockDeletePassword).toHaveBeenCalled();
|
|
110
|
+
expect(result).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return false when credentials not found', () => {
|
|
114
|
+
mockDeletePassword.mockImplementation(() => {
|
|
115
|
+
throw new Error('Item not found');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = deleteCredentials();
|
|
119
|
+
|
|
120
|
+
expect(result).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('hasCredentials', () => {
|
|
125
|
+
it('should return true when credentials exist', () => {
|
|
126
|
+
mockGetPassword.mockReturnValue(JSON.stringify({ accessToken: 'test' }));
|
|
127
|
+
|
|
128
|
+
const result = hasCredentials();
|
|
129
|
+
|
|
130
|
+
expect(result).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return false when no credentials', () => {
|
|
134
|
+
mockGetPassword.mockReturnValue(null);
|
|
135
|
+
|
|
136
|
+
const result = hasCredentials();
|
|
137
|
+
|
|
138
|
+
expect(result).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
package/__tests__/tools.test.js
CHANGED
|
@@ -13,6 +13,22 @@ const LinkedInAPI = require('../src/linkedin-api');
|
|
|
13
13
|
// Mock the database module
|
|
14
14
|
jest.mock('../src/database');
|
|
15
15
|
|
|
16
|
+
// Mock the auth modules
|
|
17
|
+
jest.mock('../src/auth/local-server', () => ({
|
|
18
|
+
startCallbackServer: jest.fn().mockResolvedValue({
|
|
19
|
+
port: 8880,
|
|
20
|
+
nonce: 'test-nonce-1234567890abcdef',
|
|
21
|
+
waitForCallback: jest.fn().mockReturnValue(new Promise(() => {})) // Never resolves in tests
|
|
22
|
+
})
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../src/auth/token-storage', () => ({
|
|
26
|
+
storeCredentials: jest.fn(),
|
|
27
|
+
getCredentials: jest.fn().mockReturnValue(null),
|
|
28
|
+
deleteCredentials: jest.fn(),
|
|
29
|
+
hasCredentials: jest.fn().mockReturnValue(false)
|
|
30
|
+
}));
|
|
31
|
+
|
|
16
32
|
// Mock environment variables
|
|
17
33
|
process.env.LINKEDIN_CLIENT_ID = 'test_client_id';
|
|
18
34
|
process.env.LINKEDIN_CLIENT_SECRET = 'test_secret';
|
|
@@ -285,17 +301,19 @@ describe('LinkedIn MCP Tools - TDD', () => {
|
|
|
285
301
|
expect(result).toHaveProperty('authUrl');
|
|
286
302
|
expect(result).toHaveProperty('state');
|
|
287
303
|
expect(result).toHaveProperty('instructions');
|
|
288
|
-
expect(result.authUrl).toContain('linkedin.com/oauth');
|
|
289
304
|
});
|
|
290
305
|
|
|
291
|
-
it('should
|
|
306
|
+
it('should return OAuth relay URL with local callback params', async () => {
|
|
292
307
|
const result = await tools.linkedin_get_auth_url();
|
|
293
308
|
|
|
294
|
-
|
|
295
|
-
expect(
|
|
296
|
-
expect(
|
|
297
|
-
expect(
|
|
298
|
-
|
|
309
|
+
// Uses OAuth relay service with local callback server
|
|
310
|
+
expect(result.authUrl).toContain('/auth/linkedin');
|
|
311
|
+
expect(result.authUrl).toContain('port=');
|
|
312
|
+
expect(result.authUrl).toContain('nonce=');
|
|
313
|
+
// State is now a cryptographic nonce, not a static string
|
|
314
|
+
expect(result.state).toBeTruthy();
|
|
315
|
+
expect(result.state.length).toBeGreaterThan(20);
|
|
316
|
+
expect(result.instructions).toContain('authenticate with LinkedIn');
|
|
299
317
|
});
|
|
300
318
|
});
|
|
301
319
|
|