k-linkedin-mcp 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -2,21 +2,35 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## [0.2.1] — 2026-03-22
6
+
7
+ ### Fixed
8
+ - [x] `setAccessToken` now async with best-effort `GET /v2/userinfo` — populates `member_id`, `name`, `email` in token.json (required for posting tools that call `_memberUrn()`)
9
+ - [x] Status CLI display: `State dir` instead of `Admin env file` path
10
+ - [x] Redirect URI dynamically computed from port override in `runOAuthFlow` (was using hardcoded `config.oauth.redirect_uri`)
11
+
12
+ ### Added
13
+ - [x] linkedin-mcp registered in Codex (`~/.codex/config.toml`), Copilot (`~/.copilot/mcp-config.json`), Vibe (`~/.vibe/config.toml`) — HTTP primary + stdio fallback
14
+
15
+ ---
16
+
5
17
  ## [0.2.0] — 2026-03-22
6
18
 
7
19
  ### Added
8
20
  - [x] Unified credential management — CLI, HTTP, Telegram: `client-id set/unset`, `client-secret set/unset`, `token set/unset`
9
21
  - [x] Admin env file pattern (`LINKEDIN_ADMIN_ENV_FILE`, default `/data/linkedin-admin.env`) — persists credentials across restarts
10
22
  - [x] `auth --port N` option to avoid `EADDRINUSE` (default port moved to 3001 via `config.json`)
11
- - [x] OAuth callback port externalized to `config.json` (not hardcoded — DEFAULTS remain 3000 as last-resort fallback)
23
+ - [x] OAuth callback port externalized to `config.json` (DEFAULTS remain 3000 as last-resort fallback only)
12
24
  - [x] New HTTP POST routes: `/admin/{client-id,client-secret,token}/{set,unset}`
13
25
  - [x] New Telegram commands: `/token_set`, `/token_unset`, `/client_id_set`, `/client_id_unset`, `/client_secret_set`, `/client_secret_unset`
14
- - [x] Status table cleaned: shows `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`, `OAuth Token` — Telegram token removed
26
+ - [x] Status table: shows `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`, `OAuth Token` — Telegram token removed
15
27
  - [x] 61 tests (18 new) — all pass
16
28
 
17
29
  ### Fixed
18
30
  - [x] `TELEGRAM_LINKEDIN_TOKEN` → `TELEGRAM_LINKEDIN_HOMELAB_TOKEN` reference in HTTP status handler
19
31
 
32
+ ---
33
+
20
34
  ## [0.1.0] — 2026-03-22
21
35
 
22
36
  ### Added
@@ -27,5 +41,5 @@
27
41
  - [x] Token lifecycle management — 60-day expiry tracking, `tokenSummary` with days_left
28
42
  - [x] Test suite — 41 tests, 5 files, 0 live API calls (mocked fetch + temp dirs)
29
43
  - [x] Docker image (`oven/bun:1-slim`) + `deploy/docker-compose.yml` with Traefik labels
30
- - [x] GitLab CI — test → build deploy pipeline
44
+ - [x] GitLab CI — test → deploy pipeline (homelab runner, no SSH)
31
45
  - [x] Editable install via `bun link`
package/README.md CHANGED
@@ -34,29 +34,30 @@ linkedin-mcp/
34
34
  │ ├── http_app.js ← Express HTTP app: /health /admin/* /mcp
35
35
  │ ├── .env.example ← required secrets template
36
36
  │ └── admin/
37
- │ ├── cli.js ← Commander CLI (auth / status / logout / logs / health / urls / guide)
38
- ├── service.js ← shared text helpers (statusSummaryText, healthSummaryText, …)
39
- │ ├── oauth.js LinkedIn OAuth 2.0 + OIDC flow
40
- └── telegram.js optional Telegram bot admin
37
+ │ ├── cli.js ← Commander CLI (auth / status / logout / logs / health / urls
38
+ │ / client-id / client-secret / token)
39
+ │ ├── service.js unified admin kernel single source of truth for all surfaces
40
+ ├── oauth.js LinkedIn OAuth 2.0 + OIDC flow (configurable callback port)
41
+ │ └── telegram.js ← optional Telegram bot admin (15 commands)
41
42
  ├── tools/
42
43
  │ ├── registry.js ← listTools() + callTool()
43
44
  │ ├── guide.js ← linkedin_guide
44
45
  │ ├── profile.js ← linkedin_get_profile, linkedin_auth_status
45
46
  │ ├── posts.js ← linkedin_create_post, linkedin_create_image_post, linkedin_delete_post
46
47
  │ └── social.js ← linkedin_like_post, linkedin_create_comment
47
- ├── tests/ ← bun test (41 tests, 0 deps on live API)
48
+ ├── tests/ ← bun test (61 tests, 0 deps on live API)
48
49
  ├── deploy/
49
50
  │ ├── docker-compose.yml
50
51
  │ └── docker-compose.override.example.yml
51
52
  ├── Dockerfile
52
53
  ├── .gitlab-ci.yml
53
- └── config.json ← non-secret defaults (port 8095, state dir, scopes)
54
+ └── config.json ← non-secret defaults (port 8095, OAuth port 3001, state dir, scopes)
54
55
  ```
55
56
 
56
57
  **Transport strategy:**
57
58
 
58
59
  ```
59
- Agent / Claude / Gemini
60
+ Agent / Claude / Gemini / Codex / Copilot / Vibe
60
61
 
61
62
  ├─── HTTP (homelab) ──→ https://linkedin.kpihx-labs.com/mcp (primary)
62
63
  └─── stdio (local) ──→ ~/.bun/bin/linkedin-mcp serve (fallback)
@@ -84,37 +85,59 @@ Agent / Claude / Gemini
84
85
  ### CLI (`linkedin-admin`)
85
86
 
86
87
  ```bash
87
- linkedin-admin auth # Run OAuth flow — opens browser, saves token
88
- linkedin-admin status # Token status + profile
89
- linkedin-admin status --json # Machine-readable JSON
90
- linkedin-admin logout # Clear saved token
91
- linkedin-admin logs --lines 50
92
- linkedin-admin health # HTTP server reachability
93
- linkedin-admin urls # All public/private endpoints
94
- linkedin-admin guide # Full help text
88
+ # Authentication
89
+ linkedin-admin auth [--port N] # OAuth flow opens browser, saves token (default port 3001)
90
+ linkedin-admin token set <value> # Set access token directly (fetches profile, no browser)
91
+ linkedin-admin token unset # Clear stored token (same as logout)
92
+ linkedin-admin logout # Clear stored token
93
+
94
+ # Credential management (stored in admin env file)
95
+ linkedin-admin client-id set <v> # Set LINKEDIN_CLIENT_ID
96
+ linkedin-admin client-id unset # Clear LINKEDIN_CLIENT_ID
97
+ linkedin-admin client-secret set <v>
98
+ linkedin-admin client-secret unset
99
+
100
+ # Status & observability
101
+ linkedin-admin status # Credentials table + token summary
102
+ linkedin-admin status --json # Machine-readable JSON
103
+ linkedin-admin logs [--lines 50] # Tail admin log
104
+ linkedin-admin health # HTTP server reachability
105
+ linkedin-admin urls # All public/private endpoints
95
106
  ```
96
107
 
97
108
  ### HTTP admin
98
109
 
99
- | Endpoint | Description |
100
- |----------|-------------|
101
- | `GET /health` | Liveness probe |
102
- | `GET /admin/status` | Token status + Telegram runtime state |
103
- | `GET /admin/help` | All commands reference |
104
- | `GET /admin/logs?lines=50` | Recent admin log tail |
105
- | `POST /mcp` | MCP Streamable HTTP transport |
110
+ | Endpoint | Method | Description |
111
+ |----------|--------|-------------|
112
+ | `/health` | GET | Liveness probe |
113
+ | `/admin/status` | GET | Full runtime status (token + Telegram state) |
114
+ | `/admin/help` | GET | All commands reference |
115
+ | `/admin/logs?lines=50` | GET | Recent admin log tail |
116
+ | `/admin/client-id/set` | POST | Body: `{ "value": "..." }` |
117
+ | `/admin/client-id/unset` | POST | Clear LINKEDIN_CLIENT_ID |
118
+ | `/admin/client-secret/set` | POST | Body: `{ "value": "..." }` |
119
+ | `/admin/client-secret/unset` | POST | Clear LINKEDIN_CLIENT_SECRET |
120
+ | `/admin/token/set` | POST | Body: `{ "value": "..." }` — sets token + fetches profile |
121
+ | `/admin/token/unset` | POST | Clear stored token |
122
+ | `/mcp` | POST/GET/DELETE | MCP Streamable HTTP transport |
106
123
 
107
124
  ### Telegram bot (optional)
108
125
 
109
- Set `TELEGRAM_LINKEDIN_TOKEN` + `TELEGRAM_CHAT_IDS` and the server polls every 5 s.
126
+ Set `TELEGRAM_LINKEDIN_HOMELAB_TOKEN` + `TELEGRAM_CHAT_IDS` server polls every 5 s.
110
127
 
111
128
  | Command | Action |
112
129
  |---------|--------|
113
130
  | `/start` `/help` | Full command reference |
114
- | `/status` | Token + service status |
131
+ | `/status` | Credentials + token status |
115
132
  | `/health` | HTTP server health |
116
133
  | `/urls` | Endpoint map |
117
134
  | `/logs [n]` | Last n admin log lines (default 20) |
135
+ | `/token_set <value>` | Set access token (fetches profile) |
136
+ | `/token_unset` | Clear stored token |
137
+ | `/client_id_set <value>` | Set LINKEDIN_CLIENT_ID |
138
+ | `/client_id_unset` | Clear LINKEDIN_CLIENT_ID |
139
+ | `/client_secret_set <value>` | Set LINKEDIN_CLIENT_SECRET |
140
+ | `/client_secret_unset` | Clear LINKEDIN_CLIENT_SECRET |
118
141
 
119
142
  ---
120
143
 
@@ -124,14 +147,21 @@ Set `TELEGRAM_LINKEDIN_TOKEN` + `TELEGRAM_CHAT_IDS` and the server polls every 5
124
147
 
125
148
  1. Go to [LinkedIn Developer Portal](https://developer.linkedin.com)
126
149
  2. Create an app → add products: **"Sign In with LinkedIn using OpenID Connect"** + **"Share on LinkedIn"**
127
- 3. Set redirect URI: `http://localhost:3000/callback`
150
+ 3. Set redirect URI: `http://localhost:3001/callback`
128
151
  4. Note `Client ID` and `Client Secret`
129
152
 
130
153
  ### 2. Configure secrets
131
154
 
132
155
  ```bash
133
- cp src/.env.example .env
156
+ # Option A — via admin CLI (written to admin env file)
157
+ linkedin-admin client-id set <your_client_id>
158
+ linkedin-admin client-secret set <your_client_secret>
159
+
160
+ # Option B — via .env file
161
+ cp src/.env.example src/.env
134
162
  # Fill in LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET
163
+
164
+ # Option C — via bw-env / kshrc (injected at login shell)
135
165
  ```
136
166
 
137
167
  ### 3. Install & authenticate
@@ -140,8 +170,14 @@ cp src/.env.example .env
140
170
  bun install
141
171
  bun link # editable install: ~/.bun/bin/linkedin-mcp + linkedin-admin
142
172
 
143
- linkedin-admin auth # opens browser authorizes → saves token
144
- linkedin-admin status # verify token is valid
173
+ # Full OAuth flow (browser consent)
174
+ linkedin-admin auth # default port 3001
175
+ linkedin-admin auth --port 3002 # if 3001 is busy
176
+
177
+ # Or set access token directly (server/headless contexts)
178
+ linkedin-admin token set <your_access_token>
179
+
180
+ linkedin-admin status # verify credentials and token
145
181
  ```
146
182
 
147
183
  ### 4. Start MCP server
@@ -159,9 +195,9 @@ linkedin-mcp serve-http
159
195
  ## Docker / Homelab deployment
160
196
 
161
197
  ```bash
162
- cp deploy/docker-compose.yml docker-compose.yml
163
- cp deploy/docker-compose.override.example.yml docker-compose.override.yml
164
- # Edit docker-compose.override.yml with your secrets
198
+ cd deploy
199
+ cp docker-compose.override.example.yml docker-compose.override.yml
200
+ # Edit override with your secrets (or rely on CI env injection)
165
201
 
166
202
  docker compose up -d
167
203
  ```
@@ -170,6 +206,10 @@ Traefik labels in `deploy/docker-compose.yml` expose:
170
206
  - `https://linkedin.kpihx-labs.com` (primary private trusted route)
171
207
  - `https://linkedin.homelab` (fallback)
172
208
 
209
+ The container persists all state in `/data` (Docker volume):
210
+ - `/data/state/token.json` — OAuth token
211
+ - `/data/linkedin-admin.env` — credentials written by `token set` / `client-id set`
212
+
173
213
  ---
174
214
 
175
215
  ## Agent registration
@@ -192,11 +232,9 @@ Traefik labels in `deploy/docker-compose.yml` expose:
192
232
  ```json
193
233
  {
194
234
  "name": "linkedin-mcp",
195
- "version": "0.1.0",
235
+ "version": "0.2.1",
196
236
  "mcpServers": {
197
- "linkedin-mcp": {
198
- "httpUrl": "https://linkedin.kpihx-labs.com/mcp"
199
- },
237
+ "linkedin-mcp": { "httpUrl": "https://linkedin.kpihx-labs.com/mcp" },
200
238
  "linkedin-mcp--fallback": {
201
239
  "command": "/home/kpihx/.bun/bin/linkedin-mcp",
202
240
  "args": ["serve"]
@@ -205,12 +243,49 @@ Traefik labels in `deploy/docker-compose.yml` expose:
205
243
  }
206
244
  ```
207
245
 
246
+ ### Codex (`~/.codex/config.toml`)
247
+
248
+ ```toml
249
+ [mcp_servers.linkedin_mcp]
250
+ url = "https://linkedin.kpihx-labs.com/mcp"
251
+
252
+ [mcp_servers.linkedin_mcp_fallback]
253
+ command = "/home/kpihx/.bun/bin/linkedin-mcp"
254
+ args = ["serve"]
255
+ ```
256
+
257
+ ### Copilot (`~/.copilot/mcp-config.json`)
258
+
259
+ ```json
260
+ "linkedin_mcp": { "type": "http", "url": "https://linkedin.kpihx-labs.com/mcp" },
261
+ "linkedin_mcp_fallback": {
262
+ "type": "stdio",
263
+ "command": "/home/kpihx/.bun/bin/linkedin-mcp",
264
+ "args": ["serve"]
265
+ }
266
+ ```
267
+
268
+ ### Vibe (`~/.vibe/config.toml`)
269
+
270
+ ```toml
271
+ [[mcp_servers]]
272
+ name = "linkedin"
273
+ transport = "http"
274
+ url = "https://linkedin.kpihx-labs.com/mcp"
275
+
276
+ [[mcp_servers]]
277
+ name = "linkedin_fallback"
278
+ transport = "stdio"
279
+ command = "/home/kpihx/.bun/bin/linkedin-mcp"
280
+ args = ["serve"]
281
+ ```
282
+
208
283
  ---
209
284
 
210
285
  ## Tests
211
286
 
212
287
  ```bash
213
- bun test # 41 tests, 5 files, 0 live API calls
288
+ bun test # 61 tests, 5 files, 0 live API calls
214
289
  bun test --watch # watch mode
215
290
  ```
216
291
 
@@ -223,11 +298,14 @@ All tests use mocked `fetch` and temp directories — no credentials needed.
223
298
  LinkedIn personal OAuth tokens expire in **60 days**. There is no refresh token for non-Partner apps.
224
299
 
225
300
  ```
226
- Day 0 → linkedin-admin auth (OAuth flow, browser consent)
227
- Day 55+ → linkedin-admin status (shows days_left, warns if < 7)
301
+ Day 0 → linkedin-admin auth (OAuth flow, browser consent)
302
+ OR linkedin-admin token set (direct token, headless/server)
303
+ Day 55+ → linkedin-admin status (shows days_left)
228
304
  Day 60 → token invalid, re-run auth
229
305
  ```
230
306
 
307
+ Both flows produce a complete `token.json` with `member_id` populated — required for all posting tools.
308
+
231
309
  ---
232
310
 
233
311
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k-linkedin-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for LinkedIn — intent-first posting, profile management, and professional networking over stdio and HTTP.",
5
5
  "main": "src/main.js",
6
6
  "bin": {
package/src/admin/cli.js CHANGED
@@ -25,7 +25,6 @@ const { loadConfig, resolveEnv, ENV_VARS } = require("../config");
25
25
  const { tokenSummary, clearToken } = require("../token");
26
26
  const { runOAuthFlow } = require("./oauth");
27
27
  const {
28
- adminEnvFilePath,
29
28
  adminHelpText,
30
29
  appendAdminLog,
31
30
  getLogsText,
@@ -34,6 +33,7 @@ const {
34
33
  setAccessToken,
35
34
  setClientId,
36
35
  setClientSecret,
36
+ stateDir,
37
37
  statusSummaryText,
38
38
  unsetAccessToken,
39
39
  unsetClientId,
@@ -178,7 +178,7 @@ program
178
178
  }
179
179
 
180
180
  console.log();
181
- console.log(`Admin env file : ${adminEnvFilePath()}`);
181
+ console.log(`State dir : ${stateDir()}`);
182
182
  console.log();
183
183
 
184
184
  // Credentials table
@@ -297,8 +297,8 @@ const tokenCmd = program
297
297
  tokenCmd
298
298
  .command("set <value>")
299
299
  .description("Set access token directly (bypasses OAuth flow, 60-day default expiry).")
300
- .action((value) => {
301
- setAccessToken(value);
300
+ .action(async (value) => {
301
+ await setAccessToken(value);
302
302
  console.log("\nAccess token set successfully. linkedin-mcp is ready.\n");
303
303
  });
304
304
 
@@ -124,11 +124,27 @@ function unsetClientSecret() {
124
124
 
125
125
  /**
126
126
  * Persist an access token directly (bypasses OAuth flow).
127
- * Writes to token.json with a 60-day default expiry.
127
+ * Does a best-effort GET /v2/userinfo to populate member_id, name, email
128
+ * in token.json — required for posting tools. If the fetch fails (bad token
129
+ * or no network), the token is still saved without profile info.
128
130
  */
129
- function setAccessToken(value) {
130
- const config = loadConfig();
131
- saveToken(config, { access_token: value, expires_in: 5184000 });
131
+ async function setAccessToken(value) {
132
+ const config = loadConfig();
133
+ const tokenData = { access_token: value, expires_in: 5184000 };
134
+
135
+ try {
136
+ const res = await fetch("https://api.linkedin.com/v2/userinfo", {
137
+ headers: { Authorization: `Bearer ${value}` },
138
+ });
139
+ if (res.ok) {
140
+ const profile = await res.json();
141
+ if (profile.sub) tokenData.member_id = profile.sub;
142
+ if (profile.name) tokenData.name = profile.name;
143
+ if (profile.email) tokenData.email = profile.email;
144
+ }
145
+ } catch { /* best-effort only — token is saved regardless */ }
146
+
147
+ saveToken(config, tokenData);
132
148
  appendAdminLog(`token set (${_maskValue(value)})`);
133
149
  }
134
150
 
@@ -105,7 +105,7 @@ async function dispatchTelegramCommand(command, args) {
105
105
  if (command === "/token_set") {
106
106
  const value = args[0];
107
107
  if (!value) return "Usage: /token_set <access_token>";
108
- setAccessToken(value);
108
+ await setAccessToken(value);
109
109
  return "Access token set successfully. linkedin-mcp is ready.";
110
110
  }
111
111
 
package/src/http_app.js CHANGED
@@ -162,10 +162,10 @@ function adminClientSecretUnsetHandler() {
162
162
  }
163
163
 
164
164
  function adminTokenSetHandler() {
165
- return (req, res) => {
165
+ return async (req, res) => {
166
166
  const { value } = req.body || {};
167
167
  if (!value) return res.status(400).json({ ok: false, error: "Missing 'value' in request body" });
168
- setAccessToken(String(value));
168
+ await setAccessToken(String(value));
169
169
  res.json({ ok: true, action: "token set" });
170
170
  };
171
171
  }
@@ -161,16 +161,16 @@ describe("credential management (setClientSecret / unsetClientSecret)", () => {
161
161
  });
162
162
 
163
163
  describe("credential management (setAccessToken / unsetAccessToken)", () => {
164
- test("setAccessToken writes token.json", () => {
165
- setAccessToken("my-test-access-token");
164
+ test("setAccessToken writes token.json", async () => {
165
+ await setAccessToken("my-test-access-token");
166
166
  const tokenPath = require("path").join(TEST_STATE_DIR, "token.json");
167
167
  expect(fs.existsSync(tokenPath)).toBe(true);
168
168
  const data = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
169
169
  expect(data.access_token).toBe("my-test-access-token");
170
170
  });
171
171
 
172
- test("unsetAccessToken removes token.json", () => {
173
- setAccessToken("temp-token");
172
+ test("unsetAccessToken removes token.json", async () => {
173
+ await setAccessToken("temp-token");
174
174
  unsetAccessToken();
175
175
  const tokenPath = require("path").join(TEST_STATE_DIR, "token.json");
176
176
  expect(fs.existsSync(tokenPath)).toBe(false);
@@ -138,7 +138,7 @@ describe("dispatchTelegramCommand — credential management", () => {
138
138
  });
139
139
 
140
140
  test("/token_unset clears the access token", async () => {
141
- await dispatchTelegramCommand("/token_set", ["temp-token"]);
141
+ await dispatchTelegramCommand("/token_set", ["temp-token"]); // network fails gracefully
142
142
  const reply = await dispatchTelegramCommand("/token_unset", []);
143
143
  expect(reply).toContain("cleared");
144
144
  const tokenPath = path.join(TEST_STATE_DIR, "token.json");