k-linkedin-mcp 0.1.0 → 0.2.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/.gitlab-ci.yml +36 -53
- package/CHANGELOG.md +15 -0
- package/Dockerfile +1 -0
- package/PRIVACY.md +36 -0
- package/config.json +2 -2
- package/package.json +1 -1
- package/src/.env.example +2 -2
- package/src/admin/cli.js +193 -25
- package/src/admin/oauth.js +18 -14
- package/src/admin/service.js +246 -44
- package/src/admin/telegram.js +87 -26
- package/src/config.js +91 -30
- package/src/http_app.js +80 -8
- package/tests/admin_service.test.js +136 -1
- package/tests/telegram.test.js +89 -7
package/.gitlab-ci.yml
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
# linkedin-mcp — GitLab CI/CD Pipeline
|
|
2
|
-
#
|
|
2
|
+
# test: shared runner (bun image)
|
|
3
|
+
# deploy: homelab runner (runs directly on the server — no SSH needed)
|
|
3
4
|
|
|
4
5
|
stages:
|
|
5
6
|
- test
|
|
6
|
-
- build
|
|
7
7
|
- deploy
|
|
8
8
|
|
|
9
|
-
variables:
|
|
10
|
-
IMAGE_NAME: registry.gitlab.com/kpihx-labs/linkedin-mcp
|
|
11
|
-
CONTAINER_NAME: linkedin_mcp
|
|
12
|
-
|
|
13
9
|
# ── Test ──────────────────────────────────────────────────────────────────────
|
|
14
10
|
|
|
15
11
|
test:
|
|
@@ -18,61 +14,48 @@ test:
|
|
|
18
14
|
script:
|
|
19
15
|
- bun install --frozen-lockfile
|
|
20
16
|
- bun test
|
|
21
|
-
coverage: '/\d+ pass/'
|
|
22
|
-
artifacts:
|
|
23
|
-
reports:
|
|
24
|
-
junit: test-results.xml
|
|
25
|
-
when: always
|
|
26
|
-
expire_in: 7 days
|
|
27
17
|
rules:
|
|
28
18
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
29
19
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
|
30
20
|
- if: '$CI_COMMIT_BRANCH == "develop"'
|
|
31
21
|
|
|
32
|
-
# ── Build Docker image ────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
build:
|
|
35
|
-
stage: build
|
|
36
|
-
image: docker:27
|
|
37
|
-
services:
|
|
38
|
-
- docker:27-dind
|
|
39
|
-
before_script:
|
|
40
|
-
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
|
41
|
-
script:
|
|
42
|
-
- docker build -t $IMAGE_NAME:$CI_COMMIT_SHORT_SHA -t $IMAGE_NAME:latest .
|
|
43
|
-
- docker push $IMAGE_NAME:$CI_COMMIT_SHORT_SHA
|
|
44
|
-
- docker push $IMAGE_NAME:latest
|
|
45
|
-
rules:
|
|
46
|
-
- if: '$CI_COMMIT_BRANCH == "main"'
|
|
47
|
-
|
|
48
22
|
# ── Deploy to Homelab ─────────────────────────────────────────────────────────
|
|
49
23
|
|
|
50
24
|
deploy:
|
|
51
25
|
stage: deploy
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
- ssh-keyscan -H $HOMELAB_HOST >> ~/.ssh/known_hosts
|
|
26
|
+
tags:
|
|
27
|
+
- homelab
|
|
28
|
+
only:
|
|
29
|
+
- main
|
|
30
|
+
needs:
|
|
31
|
+
- test
|
|
59
32
|
script:
|
|
33
|
+
- echo "Deploying linkedin-mcp to the homelab..."
|
|
34
|
+
- cd deploy
|
|
35
|
+
- echo "LINKEDIN_CLIENT_ID=$LINKEDIN_CLIENT_ID" > .env
|
|
36
|
+
- echo "LINKEDIN_CLIENT_SECRET=$LINKEDIN_CLIENT_SECRET" >> .env
|
|
37
|
+
- echo "TELEGRAM_LINKEDIN_HOMELAB_TOKEN=$TELEGRAM_LINKEDIN_HOMELAB_TOKEN" >> .env
|
|
38
|
+
- echo "TELEGRAM_CHAT_IDS=$TELEGRAM_CHAT_IDS" >> .env
|
|
39
|
+
- echo "LINKEDIN_MCP_HTTP_HOST=0.0.0.0" >> .env
|
|
40
|
+
- echo "LINKEDIN_MCP_HTTP_PORT=8095" >> .env
|
|
41
|
+
- echo "LINKEDIN_MCP_HTTP_PATH=/mcp" >> .env
|
|
42
|
+
- echo "LINKEDIN_MCP_PUBLIC_URL=https://linkedin.kpihx-labs.com" >> .env
|
|
43
|
+
- echo "LINKEDIN_STATE_DIR=/data/state" >> .env
|
|
44
|
+
- docker compose -p linkedin-mcp config -q
|
|
45
|
+
- docker rm -f linkedin-mcp || true
|
|
46
|
+
- docker compose -p linkedin-mcp up -d --build --remove-orphans
|
|
60
47
|
- |
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# HOMELAB_SSH_KEY — private SSH key for homelab deploy user (masked, protected)
|
|
76
|
-
# HOMELAB_HOST — homelab IP/hostname reachable from CI runner (masked)
|
|
77
|
-
# CI_REGISTRY_USER — GitLab container registry user (auto)
|
|
78
|
-
# CI_REGISTRY_PASSWORD — GitLab registry password / deploy token (masked)
|
|
48
|
+
for attempt in $(seq 1 20); do
|
|
49
|
+
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' linkedin-mcp 2>/dev/null || true)"
|
|
50
|
+
if [ "$status" = "healthy" ]; then
|
|
51
|
+
break
|
|
52
|
+
fi
|
|
53
|
+
if [ "$status" = "unhealthy" ]; then
|
|
54
|
+
docker logs --tail 200 linkedin-mcp
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
sleep 3
|
|
58
|
+
done
|
|
59
|
+
- docker inspect --format '{{.State.Health.Status}}' linkedin-mcp | grep -qx healthy
|
|
60
|
+
- docker exec linkedin-mcp bun -e "fetch('http://127.0.0.1:8095/health').then(r=>{if(!r.ok)process.exit(1);console.log('health OK')}).catch(()=>process.exit(1))"
|
|
61
|
+
- docker image prune -f
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
+
## [0.2.0] — 2026-03-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- [x] Unified credential management — CLI, HTTP, Telegram: `client-id set/unset`, `client-secret set/unset`, `token set/unset`
|
|
9
|
+
- [x] Admin env file pattern (`LINKEDIN_ADMIN_ENV_FILE`, default `/data/linkedin-admin.env`) — persists credentials across restarts
|
|
10
|
+
- [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)
|
|
12
|
+
- [x] New HTTP POST routes: `/admin/{client-id,client-secret,token}/{set,unset}`
|
|
13
|
+
- [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
|
|
15
|
+
- [x] 61 tests (18 new) — all pass
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- [x] `TELEGRAM_LINKEDIN_TOKEN` → `TELEGRAM_LINKEDIN_HOMELAB_TOKEN` reference in HTTP status handler
|
|
19
|
+
|
|
5
20
|
## [0.1.0] — 2026-03-22
|
|
6
21
|
|
|
7
22
|
### Added
|
package/Dockerfile
CHANGED
|
@@ -16,6 +16,7 @@ RUN chmod +x /app/src/main.js /app/src/admin.js /app/src/admin/cli.js \
|
|
|
16
16
|
|
|
17
17
|
ENV NODE_ENV=production
|
|
18
18
|
ENV LINKEDIN_STATE_DIR=/data/state
|
|
19
|
+
ENV LINKEDIN_ADMIN_ENV_FILE=/data/linkedin-admin.env
|
|
19
20
|
ENV LINKEDIN_MCP_HTTP_HOST=0.0.0.0
|
|
20
21
|
ENV LINKEDIN_MCP_HTTP_PORT=8095
|
|
21
22
|
ENV LINKEDIN_MCP_HTTP_PATH=/mcp
|
package/PRIVACY.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Privacy Policy — linkedin-mcp
|
|
2
|
+
|
|
3
|
+
**Last updated: 2026-03-22**
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`linkedin-mcp` is a personal tool used exclusively by its author (KpihX / Ivann KAMDEM).
|
|
8
|
+
It is not a public service and has no end-users other than the author.
|
|
9
|
+
|
|
10
|
+
## Data collected
|
|
11
|
+
|
|
12
|
+
This application does **not** collect, store, or share any personal data from third parties.
|
|
13
|
+
|
|
14
|
+
The only data persisted is:
|
|
15
|
+
|
|
16
|
+
- **LinkedIn OAuth token** — stored locally on the author's own infrastructure (`~/.mcps/linkedin/token.json`), containing the access token, expiry date, and the author's own LinkedIn member ID, name, and email. This data is never transmitted to any third party.
|
|
17
|
+
- **Admin logs** — operational log lines (auth events, command invocations) stored locally in `~/.mcps/linkedin/linkedin-admin.log`. These logs are never transmitted externally.
|
|
18
|
+
|
|
19
|
+
## LinkedIn API usage
|
|
20
|
+
|
|
21
|
+
This application uses the official LinkedIn OAuth 2.0 API with the following scopes:
|
|
22
|
+
|
|
23
|
+
- `openid` — identity verification
|
|
24
|
+
- `profile` — read own profile
|
|
25
|
+
- `email` — read own email
|
|
26
|
+
- `w_member_social` — post, like, and comment as the authenticated member
|
|
27
|
+
|
|
28
|
+
All API calls are made exclusively on behalf of the authenticated member (the author) to their own LinkedIn account.
|
|
29
|
+
|
|
30
|
+
## Third-party services
|
|
31
|
+
|
|
32
|
+
No analytics, tracking, telemetry, or third-party SDKs are included.
|
|
33
|
+
|
|
34
|
+
## Contact
|
|
35
|
+
|
|
36
|
+
For any questions: [GitHub](https://github.com/KpihX/linkedin-mcp)
|
package/config.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"fallback_base_url": "https://linkedin.homelab"
|
|
9
9
|
},
|
|
10
10
|
"oauth": {
|
|
11
|
-
"redirect_port":
|
|
12
|
-
"redirect_uri": "http://localhost:
|
|
11
|
+
"redirect_port": 3001,
|
|
12
|
+
"redirect_uri": "http://localhost:3001/callback",
|
|
13
13
|
"scopes": ["openid", "profile", "email", "w_member_social"]
|
|
14
14
|
},
|
|
15
15
|
"logging": {
|
package/package.json
CHANGED
package/src/.env.example
CHANGED
|
@@ -7,8 +7,8 @@ LINKEDIN_CLIENT_SECRET=your_client_secret_here
|
|
|
7
7
|
# LINKEDIN_ACCESS_TOKEN=your_access_token_here
|
|
8
8
|
|
|
9
9
|
# Optional: Telegram admin bot
|
|
10
|
-
#
|
|
11
|
-
# TELEGRAM_CHAT_IDS=123456789,987654321
|
|
10
|
+
# TELEGRAM_LINKEDIN_HOMELAB_TOKEN=your_bot_token_here (from @BotFather)
|
|
11
|
+
# TELEGRAM_CHAT_IDS=123456789,987654321 (comma-separated chat IDs)
|
|
12
12
|
|
|
13
13
|
# Optional server overrides
|
|
14
14
|
# LINKEDIN_MCP_HTTP_HOST=0.0.0.0
|
package/src/admin/cli.js
CHANGED
|
@@ -3,37 +3,119 @@
|
|
|
3
3
|
* linkedin-mcp — Admin CLI (linkedin-admin).
|
|
4
4
|
*
|
|
5
5
|
* Commands:
|
|
6
|
-
* auth
|
|
7
|
-
* status [--json]
|
|
8
|
-
* logout
|
|
9
|
-
* logs [--lines N]
|
|
10
|
-
* health
|
|
11
|
-
* urls
|
|
12
|
-
*
|
|
6
|
+
* auth [--port N] Start OAuth 2.0 flow (opens browser, saves token)
|
|
7
|
+
* status [--json] Show credentials + token status table
|
|
8
|
+
* logout Clear stored token
|
|
9
|
+
* logs [--lines N] Tail the admin log
|
|
10
|
+
* health Show HTTP endpoint URLs
|
|
11
|
+
* urls Show all public URLs
|
|
12
|
+
* client-id set <value> Set LINKEDIN_CLIENT_ID in admin env file
|
|
13
|
+
* client-id unset Clear LINKEDIN_CLIENT_ID from admin env file
|
|
14
|
+
* client-secret set <v> Set LINKEDIN_CLIENT_SECRET in admin env file
|
|
15
|
+
* client-secret unset Clear LINKEDIN_CLIENT_SECRET from admin env file
|
|
16
|
+
* token set <value> Set access token directly (bypasses OAuth)
|
|
17
|
+
* token unset Clear stored access token (logout)
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
"use strict";
|
|
16
21
|
|
|
17
22
|
const { Command } = require("commander");
|
|
18
23
|
|
|
19
|
-
const { loadConfig }
|
|
20
|
-
const { tokenSummary, clearToken }
|
|
21
|
-
const { runOAuthFlow }
|
|
24
|
+
const { loadConfig, resolveEnv, ENV_VARS } = require("../config");
|
|
25
|
+
const { tokenSummary, clearToken } = require("../token");
|
|
26
|
+
const { runOAuthFlow } = require("./oauth");
|
|
22
27
|
const {
|
|
28
|
+
adminEnvFilePath,
|
|
23
29
|
adminHelpText,
|
|
30
|
+
appendAdminLog,
|
|
24
31
|
getLogsText,
|
|
32
|
+
getSecretsStatus,
|
|
25
33
|
healthSummaryText,
|
|
34
|
+
setAccessToken,
|
|
35
|
+
setClientId,
|
|
36
|
+
setClientSecret,
|
|
26
37
|
statusSummaryText,
|
|
38
|
+
unsetAccessToken,
|
|
39
|
+
unsetClientId,
|
|
40
|
+
unsetClientSecret,
|
|
27
41
|
urlsSummary,
|
|
28
|
-
appendAdminLog,
|
|
29
42
|
} = require("./service");
|
|
30
43
|
|
|
31
44
|
const PKG = require("../../package.json");
|
|
32
45
|
|
|
46
|
+
// ── Simple Rich-style table renderer ─────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function _pad(str, len) {
|
|
49
|
+
return String(str || "").padEnd(len);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _printStatusTable(title, rows) {
|
|
53
|
+
// rows: [{ variable, status, masked, source }]
|
|
54
|
+
const cols = [
|
|
55
|
+
{ key: "variable", label: "Variable", width: 36 },
|
|
56
|
+
{ key: "status", label: "Status", width: 11 },
|
|
57
|
+
{ key: "masked", label: "Value (masked)", width: 16 },
|
|
58
|
+
{ key: "source", label: "Source", width: 24 },
|
|
59
|
+
];
|
|
60
|
+
const tl = "╭", tr = "╮", bl = "╰", br = "╯";
|
|
61
|
+
const ml = "├", mr = "┤", cross = "┼";
|
|
62
|
+
const vl = "│";
|
|
63
|
+
const hl = "─";
|
|
64
|
+
|
|
65
|
+
const totalWidth = cols.reduce((s, c) => s + c.width + 3, 1);
|
|
66
|
+
|
|
67
|
+
// Top border
|
|
68
|
+
let top = tl;
|
|
69
|
+
cols.forEach((c, i) => {
|
|
70
|
+
top += hl.repeat(c.width + 2);
|
|
71
|
+
top += (i < cols.length - 1) ? "┬" : tr;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Header row
|
|
75
|
+
let header = vl;
|
|
76
|
+
cols.forEach((c) => {
|
|
77
|
+
header += ` ${_pad(c.label, c.width)} ${vl}`;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Middle separator
|
|
81
|
+
let mid = ml;
|
|
82
|
+
cols.forEach((c, i) => {
|
|
83
|
+
mid += hl.repeat(c.width + 2);
|
|
84
|
+
mid += (i < cols.length - 1) ? cross : mr;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Bottom border
|
|
88
|
+
let bot = bl;
|
|
89
|
+
cols.forEach((c, i) => {
|
|
90
|
+
bot += hl.repeat(c.width + 2);
|
|
91
|
+
bot += (i < cols.length - 1) ? "┴" : br;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const titlePad = Math.floor((totalWidth - 2 - title.length) / 2);
|
|
95
|
+
console.log(" ".repeat(Math.max(0, titlePad)) + title);
|
|
96
|
+
console.log(top);
|
|
97
|
+
console.log(header);
|
|
98
|
+
console.log(mid);
|
|
99
|
+
|
|
100
|
+
rows.forEach((r, idx) => {
|
|
101
|
+
let row = vl;
|
|
102
|
+
row += ` ${_pad(r.variable, cols[0].width)} ${vl}`;
|
|
103
|
+
row += ` ${_pad(r.status, cols[1].width)} ${vl}`;
|
|
104
|
+
row += ` ${_pad(r.masked, cols[2].width)} ${vl}`;
|
|
105
|
+
row += ` ${_pad(r.source, cols[3].width)} ${vl}`;
|
|
106
|
+
console.log(row);
|
|
107
|
+
if (idx < rows.length - 1) console.log(mid);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log(bot);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── CLI program ───────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
33
115
|
const program = new Command();
|
|
34
116
|
program
|
|
35
117
|
.name("linkedin-admin")
|
|
36
|
-
.description("linkedin-mcp administration: auth, status, logout, logs")
|
|
118
|
+
.description("linkedin-mcp administration: auth, status, logout, logs, credential management")
|
|
37
119
|
.version(PKG.version);
|
|
38
120
|
|
|
39
121
|
// ── auth ─────────────────────────────────────────────────────────────────────
|
|
@@ -41,7 +123,8 @@ program
|
|
|
41
123
|
program
|
|
42
124
|
.command("auth")
|
|
43
125
|
.description("Start LinkedIn OAuth 2.0 flow. Opens a browser for authorization.")
|
|
44
|
-
.
|
|
126
|
+
.option("--port <n>", "Local callback port (default 3001)", "3001")
|
|
127
|
+
.action(async (opts) => {
|
|
45
128
|
const config = loadConfig();
|
|
46
129
|
if (!config.oauth.client_id || !config.oauth.client_secret) {
|
|
47
130
|
console.error(
|
|
@@ -49,13 +132,15 @@ program
|
|
|
49
132
|
"Steps:\n" +
|
|
50
133
|
" 1. Create app at https://developer.linkedin.com\n" +
|
|
51
134
|
" 2. Add products: 'Sign In with LinkedIn using OpenID Connect' + 'Share on LinkedIn'\n" +
|
|
52
|
-
" 3. Set redirect URI: http://localhost:
|
|
53
|
-
" 4. Store credentials via bw-env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET\n"
|
|
135
|
+
" 3. Set redirect URI: http://localhost:3001/callback\n" +
|
|
136
|
+
" 4. Store credentials via bw-env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET\n" +
|
|
137
|
+
" or: linkedin-admin client-id set <value>\n",
|
|
54
138
|
);
|
|
55
139
|
process.exit(1);
|
|
56
140
|
}
|
|
141
|
+
const port = parseInt(opts.port, 10) || 3000;
|
|
57
142
|
console.log("\nStarting LinkedIn OAuth flow...");
|
|
58
|
-
console.log(`Redirect URI :
|
|
143
|
+
console.log(`Redirect URI : http://localhost:${port}/callback`);
|
|
59
144
|
console.log(`Scopes : ${config.oauth.scopes.join(", ")}\n`);
|
|
60
145
|
try {
|
|
61
146
|
const tokenData = await runOAuthFlow(config, (url) => {
|
|
@@ -63,7 +148,7 @@ program
|
|
|
63
148
|
console.log(` ${url}\n`);
|
|
64
149
|
const { execSync } = require("child_process");
|
|
65
150
|
try { execSync(`xdg-open "${url}"`, { stdio: "ignore" }); } catch { /* silent */ }
|
|
66
|
-
});
|
|
151
|
+
}, port);
|
|
67
152
|
appendAdminLog(`auth success member=${tokenData.member_id} name=${tokenData.name}`);
|
|
68
153
|
console.log(`\nAuthenticated: ${tokenData.name || "LinkedIn member"}`);
|
|
69
154
|
console.log(`Email : ${tokenData.email || "N/A"}`);
|
|
@@ -80,16 +165,42 @@ program
|
|
|
80
165
|
|
|
81
166
|
program
|
|
82
167
|
.command("status")
|
|
83
|
-
.description("Show current authentication status and token details.")
|
|
168
|
+
.description("Show current authentication status, credentials, and token details.")
|
|
84
169
|
.option("--json", "Output raw JSON")
|
|
85
170
|
.action((opts) => {
|
|
86
171
|
const config = loadConfig();
|
|
87
172
|
const summary = tokenSummary(config);
|
|
173
|
+
const secrets = getSecretsStatus();
|
|
174
|
+
|
|
88
175
|
if (opts.json) {
|
|
89
|
-
console.log(JSON.stringify(summary, null, 2));
|
|
176
|
+
console.log(JSON.stringify({ token: summary, credentials: secrets }, null, 2));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log();
|
|
181
|
+
console.log(`Admin env file : ${adminEnvFilePath()}`);
|
|
182
|
+
console.log();
|
|
183
|
+
|
|
184
|
+
// Credentials table
|
|
185
|
+
const rows = secrets.map((s) => ({
|
|
186
|
+
variable: s.name,
|
|
187
|
+
status: s.present ? "✓ set" : "✗ missing",
|
|
188
|
+
masked: s.masked,
|
|
189
|
+
source: s.source,
|
|
190
|
+
}));
|
|
191
|
+
_printStatusTable("linkedin-admin status", rows);
|
|
192
|
+
|
|
193
|
+
// Token summary below table
|
|
194
|
+
console.log();
|
|
195
|
+
if (summary.valid) {
|
|
196
|
+
console.log(`Token : valid — ${summary.name} <${summary.email}> — ${summary.days_left} days left`);
|
|
197
|
+
console.log(`Expires : ${summary.expires_at}`);
|
|
198
|
+
} else if (summary.present) {
|
|
199
|
+
console.log("Token : present but EXPIRED — run 'linkedin-admin auth' or 'linkedin-admin token set <value>'");
|
|
90
200
|
} else {
|
|
91
|
-
console.log(
|
|
201
|
+
console.log("Token : absent — run 'linkedin-admin auth' or 'linkedin-admin token set <value>'");
|
|
92
202
|
}
|
|
203
|
+
console.log();
|
|
93
204
|
});
|
|
94
205
|
|
|
95
206
|
// ── logout ────────────────────────────────────────────────────────────────────
|
|
@@ -133,13 +244,70 @@ program
|
|
|
133
244
|
console.log(`\n${urlsSummary()}\n`);
|
|
134
245
|
});
|
|
135
246
|
|
|
136
|
-
// ──
|
|
247
|
+
// ── client-id ─────────────────────────────────────────────────────────────────
|
|
137
248
|
|
|
138
|
-
program
|
|
139
|
-
.command("
|
|
140
|
-
.description("
|
|
249
|
+
const clientIdCmd = program
|
|
250
|
+
.command("client-id")
|
|
251
|
+
.description("Manage LINKEDIN_CLIENT_ID in the admin env file.");
|
|
252
|
+
|
|
253
|
+
clientIdCmd
|
|
254
|
+
.command("set <value>")
|
|
255
|
+
.description("Set LINKEDIN_CLIENT_ID.")
|
|
256
|
+
.action((value) => {
|
|
257
|
+
setClientId(value);
|
|
258
|
+
console.log("\nLINKEDIN_CLIENT_ID set successfully.\n");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
clientIdCmd
|
|
262
|
+
.command("unset")
|
|
263
|
+
.description("Clear LINKEDIN_CLIENT_ID from the admin env file.")
|
|
264
|
+
.action(() => {
|
|
265
|
+
unsetClientId();
|
|
266
|
+
console.log("\nLINKEDIN_CLIENT_ID cleared.\n");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── client-secret ─────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
const clientSecretCmd = program
|
|
272
|
+
.command("client-secret")
|
|
273
|
+
.description("Manage LINKEDIN_CLIENT_SECRET in the admin env file.");
|
|
274
|
+
|
|
275
|
+
clientSecretCmd
|
|
276
|
+
.command("set <value>")
|
|
277
|
+
.description("Set LINKEDIN_CLIENT_SECRET.")
|
|
278
|
+
.action((value) => {
|
|
279
|
+
setClientSecret(value);
|
|
280
|
+
console.log("\nLINKEDIN_CLIENT_SECRET set successfully.\n");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
clientSecretCmd
|
|
284
|
+
.command("unset")
|
|
285
|
+
.description("Clear LINKEDIN_CLIENT_SECRET from the admin env file.")
|
|
286
|
+
.action(() => {
|
|
287
|
+
unsetClientSecret();
|
|
288
|
+
console.log("\nLINKEDIN_CLIENT_SECRET cleared.\n");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── token ─────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
const tokenCmd = program
|
|
294
|
+
.command("token")
|
|
295
|
+
.description("Manage the OAuth access token directly.");
|
|
296
|
+
|
|
297
|
+
tokenCmd
|
|
298
|
+
.command("set <value>")
|
|
299
|
+
.description("Set access token directly (bypasses OAuth flow, 60-day default expiry).")
|
|
300
|
+
.action((value) => {
|
|
301
|
+
setAccessToken(value);
|
|
302
|
+
console.log("\nAccess token set successfully. linkedin-mcp is ready.\n");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
tokenCmd
|
|
306
|
+
.command("unset")
|
|
307
|
+
.description("Clear the stored access token (same as logout).")
|
|
141
308
|
.action(() => {
|
|
142
|
-
|
|
309
|
+
unsetAccessToken();
|
|
310
|
+
console.log("\nAccess token cleared. Run 'linkedin-admin auth' to re-authenticate.\n");
|
|
143
311
|
});
|
|
144
312
|
|
|
145
313
|
module.exports = { program };
|
package/src/admin/oauth.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* linkedin-mcp — OAuth 2.0 flow manager.
|
|
3
3
|
*
|
|
4
|
-
* Launches a local HTTP server on
|
|
4
|
+
* Launches a local HTTP server on a configurable port (default 3000) to catch
|
|
5
5
|
* the authorization code from LinkedIn, then exchanges it for an access token.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* const { runOAuthFlow } = require("./oauth");
|
|
9
|
-
* const tokenData = await runOAuthFlow(config);
|
|
9
|
+
* const tokenData = await runOAuthFlow(config, onUrl);
|
|
10
|
+
* // or with port override:
|
|
11
|
+
* const tokenData = await runOAuthFlow(config, onUrl, 8080);
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
"use strict";
|
|
@@ -21,11 +23,11 @@ const LI_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken";
|
|
|
21
23
|
/**
|
|
22
24
|
* Build the LinkedIn OAuth authorization URL.
|
|
23
25
|
*/
|
|
24
|
-
function buildAuthUrl(config, state) {
|
|
26
|
+
function buildAuthUrl(config, state, redirectUri) {
|
|
25
27
|
const params = new URLSearchParams({
|
|
26
28
|
response_type: "code",
|
|
27
29
|
client_id: config.oauth.client_id,
|
|
28
|
-
redirect_uri:
|
|
30
|
+
redirect_uri: redirectUri,
|
|
29
31
|
state,
|
|
30
32
|
scope: config.oauth.scopes.join(" "),
|
|
31
33
|
});
|
|
@@ -35,11 +37,11 @@ function buildAuthUrl(config, state) {
|
|
|
35
37
|
/**
|
|
36
38
|
* Exchange an authorization code for an access token.
|
|
37
39
|
*/
|
|
38
|
-
async function exchangeCode(config, code) {
|
|
40
|
+
async function exchangeCode(config, code, redirectUri) {
|
|
39
41
|
const body = new URLSearchParams({
|
|
40
42
|
grant_type: "authorization_code",
|
|
41
43
|
code,
|
|
42
|
-
redirect_uri:
|
|
44
|
+
redirect_uri: redirectUri,
|
|
43
45
|
client_id: config.oauth.client_id,
|
|
44
46
|
client_secret: config.oauth.client_secret,
|
|
45
47
|
});
|
|
@@ -68,18 +70,19 @@ async function fetchProfile(accessToken) {
|
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* Run the full OAuth flow:
|
|
71
|
-
* 1. Start local callback server
|
|
73
|
+
* 1. Start local callback server on the given port
|
|
72
74
|
* 2. Return the auth URL for the user to open
|
|
73
75
|
* 3. Wait for callback with code
|
|
74
76
|
* 4. Exchange code → token
|
|
75
77
|
* 5. Fetch profile → enrich token
|
|
76
78
|
* 6. Persist token to disk
|
|
77
79
|
*
|
|
78
|
-
* @param {object}
|
|
79
|
-
* @param {function} onUrl
|
|
80
|
+
* @param {object} config
|
|
81
|
+
* @param {function} onUrl - Called with the auth URL string so caller can print/open it
|
|
82
|
+
* @param {number} [portOverride] - Override the callback port (default: config.oauth.redirect_port || 3000)
|
|
80
83
|
* @returns {Promise<object>} tokenData
|
|
81
84
|
*/
|
|
82
|
-
function runOAuthFlow(config, onUrl) {
|
|
85
|
+
function runOAuthFlow(config, onUrl, portOverride) {
|
|
83
86
|
return new Promise((resolve, reject) => {
|
|
84
87
|
if (!config.oauth.client_id || !config.oauth.client_secret) {
|
|
85
88
|
return reject(new Error(
|
|
@@ -88,9 +91,10 @@ function runOAuthFlow(config, onUrl) {
|
|
|
88
91
|
));
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
+
const port = portOverride || config.oauth.redirect_port || 3000;
|
|
95
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
96
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
97
|
+
const authUrl = buildAuthUrl(config, state, redirectUri);
|
|
94
98
|
|
|
95
99
|
const server = http.createServer(async (req, res) => {
|
|
96
100
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
@@ -125,7 +129,7 @@ function runOAuthFlow(config, onUrl) {
|
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
try {
|
|
128
|
-
const tokenData = await exchangeCode(config, code);
|
|
132
|
+
const tokenData = await exchangeCode(config, code, redirectUri);
|
|
129
133
|
const profile = await fetchProfile(tokenData.access_token);
|
|
130
134
|
const enriched = {
|
|
131
135
|
...tokenData,
|