@ldraney/mcp-linkedin 0.2.3 → 0.3.1

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 CHANGED
@@ -7,13 +7,13 @@ 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 # 18 tool implementations
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
14
14
  database.js # SQLite wrapper for scheduled posts
15
15
  scheduler.js # Background scheduler daemon
16
- __tests__/ # Jest test suite (118 tests)
16
+ __tests__/ # Jest test suite (135 tests)
17
17
  ```
18
18
 
19
19
  ## Current Tools (Phase 4 Complete)
@@ -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 (97 passing)
77
+ npm test # Run tests (135 passing)
77
78
  ```
78
79
 
79
80
  ## Roadmap
@@ -220,5 +221,5 @@ npm run test:manual # Manual API test (test_post.js)
220
221
 
221
222
  - [LinkedIn Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api)
222
223
  - [LinkedIn Authentication](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication)
223
- - [USER_STORY.md](./USER_STORY.md) - Full user stories
224
+ - [User Story](https://ldraney.github.io/mcp-linkedin/user-story.html) - Full user stories
224
225
  - [LINKEDIN_API_REFERENCE.md](./LINKEDIN_API_REFERENCE.md) - API details
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Draney
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/MCP_SETUP.md CHANGED
@@ -236,7 +236,7 @@ Access tokens expire after 60 days. To refresh:
236
236
 
237
237
  For issues or questions:
238
238
  - Check the [main README](./README.md)
239
- - Review [USER_STORY.md](./USER_STORY.md) for feature details
239
+ - Review [User Story](https://ldraney.github.io/mcp-linkedin/user-story.html) for feature details
240
240
  - Open an issue on GitHub
241
241
 
242
242
  ---
package/README.md CHANGED
@@ -1,115 +1,82 @@
1
1
  # mcp-linkedin
2
2
 
3
- MCP server for managing LinkedIn posts - create text, image, video, document, poll, and multi-image posts. Schedule posts, add comments and reactions.
3
+ **Post to LinkedIn from Claude Desktop.**
4
4
 
5
- ## Installation
5
+ Install in 30 seconds. Authenticate once. Post forever.
6
6
 
7
- ### MCPB Bundle (One-Click)
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
- ### npm / npx
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
- ### Manual MCP Config
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
- ## Tools (20 Total)
35
-
36
- ### Content Creation
37
- | Tool | Description |
38
- |------|-------------|
39
- | `linkedin_create_post` | Create text posts |
40
- | `linkedin_create_post_with_link` | Posts with article/link preview |
41
- | `linkedin_create_post_with_image` | Upload image + create post |
42
- | `linkedin_create_post_with_video` | Upload video + create post |
43
- | `linkedin_create_post_with_document` | Upload PDF/PPT/DOC + create post |
44
- | `linkedin_create_post_with_multi_images` | Upload 2-20 images + create post |
45
- | `linkedin_create_poll` | Create poll posts (2-4 options) |
46
-
47
- ### Content Management
48
- | Tool | Description |
49
- |------|-------------|
50
- | `linkedin_get_my_posts` | Retrieve recent posts (paginated) |
51
- | `linkedin_update_post` | Edit existing posts |
52
- | `linkedin_delete_post` | Delete by URN |
53
-
54
- ### Social Interactions
55
- | Tool | Description |
56
- |------|-------------|
57
- | `linkedin_add_comment` | Add comment to a post |
58
- | `linkedin_add_reaction` | React to a post (LIKE, PRAISE, etc.) |
59
-
60
- ### Scheduling
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
- This runs a daemon that checks every minute for posts due to publish.
50
+ Your credentials stay on your machine. The OAuth relay only handles the initial handshake.
97
51
 
98
- ## Development
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 # 118 tests
103
- npm run test:coverage # Coverage report
104
- npm start # Start MCP server
105
- npm run scheduler # Start scheduler daemon
76
+ npm test # 135 tests
106
77
  ```
107
78
 
108
- ## Documentation
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,68 @@
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
+ - Environment variables (`.env`) are supported as a fallback for development setups
19
+ - Legacy plaintext credential file support (`~/.mcp-linkedin-credentials.json`) was removed in v0.3.x
20
+ - Token values are masked in all tool outputs to prevent accidental exposure in logs or chat history
21
+ - The OAuth relay server (`fly.io`) only handles the initial handshake redirect -- it never sees or stores your access token
22
+
23
+ ### Data Flow
24
+
25
+ ```
26
+ Your Machine External
27
+ ┌─────────────────┐ ┌──────────────┐
28
+ │ Claude Desktop │ │ │
29
+ │ ├─ mcp-linkedin│──────────►│ LinkedIn API │
30
+ │ └─ OS Keychain│ │ │
31
+ └─────────────────┘ └──────────────┘
32
+
33
+ │ (OAuth only)
34
+
35
+ ┌─────────────────┐
36
+ │ OAuth Relay │
37
+ │ (fly.io) │
38
+ │ Redirect only │
39
+ └─────────────────┘
40
+ ```
41
+
42
+ - All LinkedIn API calls go **directly** from your machine to LinkedIn
43
+ - No telemetry, analytics, or data collection
44
+ - Scheduled posts are stored in a **local** SQLite database on your machine
45
+
46
+ ### OAuth Security
47
+
48
+ - CSRF protection via cryptographic nonces on every OAuth flow
49
+ - Tokens are captured by a local callback server and stored directly in your keychain
50
+ - The `w_member_social` scope is the only permission requested (post and interact on your behalf)
51
+
52
+ ### What We Don't Do
53
+
54
+ - We never store your credentials on any remote server
55
+ - We never log or transmit your LinkedIn data
56
+ - We never access your LinkedIn connections, messages, or profile data beyond what you explicitly post
57
+
58
+ ## Supported Versions
59
+
60
+ | Version | Supported |
61
+ |---------|-----------|
62
+ | 0.3.x | Yes |
63
+ | 0.2.x | Yes |
64
+ | < 0.2 | No |
65
+
66
+ ## Dependencies
67
+
68
+ 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
+ });