vxtalent-hr-cli 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # vxTalent HR CLI
2
+
3
+ Zero-dependency Node.js CLI that turns the remote `hr-tools` MCP into a local stdio MCP server for WorkBuddy, Qwen Work, and other MCP agents.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18.17+
8
+ - Public OAuth client: `vxtalent-hr-cli`
9
+ - Registered loopback redirect URI: `http://127.0.0.1/callback`
10
+ - Remote MCP transport: Streamable HTTP (`MCP_TRANSPORT=streamable-http`)
11
+ - The CLI package must be published to npm before `npx -y vxtalent-hr-cli` works outside this repository.
12
+
13
+ ## One-command Install
14
+
15
+ ```bash
16
+ npx -y vxtalent-hr-cli install \
17
+ --issuer https://mcp.vxhcm.com \
18
+ --platform workbuddy \
19
+ --write \
20
+ --auth
21
+ ```
22
+
23
+ The command writes this connector config:
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "vxtalent-hr": {
29
+ "type": "stdio",
30
+ "command": "npx",
31
+ "args": ["-y", "vxtalent-hr-cli", "mcp"],
32
+ "env": {
33
+ "VXTALENT_ISSUER": "https://mcp.vxhcm.com",
34
+ "VXTALENT_MCP_URL": "https://mcp.vxhcm.com/mcp",
35
+ "VXTALENT_CLIENT_ID": "vxtalent-hr-cli"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ ## Supported Platforms
43
+
44
+ | Platform | Config path | `--platform` value |
45
+ |---|---|---|
46
+ | WorkBuddy | `~/.workbuddy/mcp.json` | `workbuddy` |
47
+ | Qwen Work (千问办公) | `~/.qwen-work/mcp.json` | `qwen-work` |
48
+ | Doubao Work (豆包工作) | `~/.doubao-work/mcp.json` | `doubao-work` |
49
+ | Custom | (via `--config <path>`) | `custom` |
50
+
51
+ ## Commands
52
+
53
+ ```bash
54
+ npx -y vxtalent-hr-cli auth --issuer https://mcp.vxhcm.com
55
+ npx -y vxtalent-hr-cli status --issuer https://mcp.vxhcm.com
56
+ npx -y vxtalent-hr-cli logout --issuer https://mcp.vxhcm.com
57
+ npx -y vxtalent-hr-cli mcp --issuer https://mcp.vxhcm.com
58
+ ```
59
+
60
+ The CLI uses Authorization Code + PKCE S256. Tokens are stored in `~/.vxtalent/auth.json`, and the access token is refreshed automatically five minutes before expiry.
61
+
62
+ ## Authorization & Logout
63
+
64
+ The stdio bridge exposes two extra chat tools so end users do not need the
65
+ terminal:
66
+
67
+ - `auth_status` ? show whether this device is authorized and when the token expires.
68
+ - `auth_logout` ? revoke the local token and delete `~/.vxtalent/auth.json`. After
69
+ logout the next tool call triggers a fresh browser authorization automatically.
70
+
71
+ Automatic browser authorization is **on by default**: whenever the local token
72
+ is missing or expired, the bridge opens the OAuth consent page. Pass
73
+ `--no-auto-auth` to disable this and fail with a clear message instead.
74
+
75
+ ## Platform-Specific Quick Start
76
+
77
+ ### WorkBuddy
78
+
79
+ ```bash
80
+ npx -y vxtalent-hr-cli install \
81
+ --issuer https://mcp.vxhcm.com \
82
+ --platform workbuddy \
83
+ --write --auth
84
+ ```
85
+
86
+ ### Qwen Work (千问办公)
87
+
88
+ ```bash
89
+ npx -y vxtalent-hr-cli install \
90
+ --issuer https://mcp.vxhcm.com \
91
+ --platform qwen-work \
92
+ --write --auth
93
+ ```
94
+
95
+ ## Doubao Work (豆包工作)
96
+
97
+ Doubao's "自定义连接器" dialog supports both STDIO and HTTP transports:
98
+
99
+ - STDIO: 服务器名称 `vxtalent-hr`,传输类型 STDIO,命令填 `node`(或
100
+ `vxtalent-hr-cli`),参数填 `D:/.../cli/index.mjs mcp`(或 `mcp`),环境变量
101
+ 填 `VXTALENT_ISSUER` / `VXTALENT_MCP_URL` / `VXTALENT_CLIENT_ID`。
102
+ - HTTP: 传输类型 Streamable HTTP,地址填 `https://mcp.vxhcm.com/mcp`,
103
+ 认证选 OAuth,scope 填 `hr:read`(与 WorkBuddy 同一路径,无需 CLI)。
104
+
105
+ ```bash
106
+ node cli/index.mjs install --issuer https://mcp.vxhcm.com --platform doubao-work --write
107
+ ```
package/index.mjs ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { runAuth } from "./lib/auth.mjs";
3
+ import { install } from "./lib/install.mjs";
4
+ import { startMcpServer } from "./lib/mcp-server.mjs";
5
+ import { clearTokens, loadTokens, revokeTokens } from "./lib/token-manager.mjs";
6
+ import { resolveOptions } from "./lib/config.mjs";
7
+
8
+ function printHelp() {
9
+ console.log(`vxTalent HR MCP CLI
10
+
11
+ Usage:
12
+ vxtalent-hr-cli auth [options] Login and authorize this device
13
+ vxtalent-hr-cli install [options] Generate or write agent MCP config
14
+ vxtalent-hr-cli mcp [options] Run as a stdio MCP bridge (spawned by an agent, not for direct terminal use)
15
+ vxtalent-hr-cli status [options] Show authorization status
16
+ vxtalent-hr-cli logout [options] Revoke and remove local tokens
17
+
18
+ Options:
19
+ --issuer <url> OAuth/API base URL, or VXTALENT_ISSUER
20
+ --mcp-url <url> Remote MCP Streamable HTTP URL, or VXTALENT_MCP_URL
21
+ --client-id <id> OAuth public client ID, or VXTALENT_CLIENT_ID
22
+ --scope <scopes> OAuth scopes, default: hr:read
23
+ --platform <name> workbuddy | qwen-work | doubao-work | custom
24
+ --config <path> Agent MCP config path
25
+ --write Write config instead of only printing it
26
+ --local Use this local checkout instead of npx (for dev/testing)
27
+ --no-auto-auth Disable automatic browser authorization when token is missing (default: on)
28
+ --auth Start browser authorization after install
29
+ --help, -h Show help
30
+
31
+ Examples:
32
+ vxtalent-hr-cli install --issuer https://hr.example.com --platform workbuddy --write --auth
33
+ vxtalent-hr-cli mcp --issuer https://hr.example.com --mcp-url https://hr.example.com/mcp
34
+ `);
35
+ }
36
+
37
+ function parseArgs(argv) {
38
+ const args = { _: [] };
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const value = argv[i];
41
+ const next = argv[i + 1];
42
+ if (value === "--help" || value === "-h") {
43
+ args.help = true;
44
+ } else if (value === "--write") {
45
+ args.write = true;
46
+ } else if (value === "--auth") {
47
+ args.auth = true;
48
+ } else if (value.startsWith("--")) {
49
+ const key = value.slice(2);
50
+ if (next === undefined || next.startsWith("--")) {
51
+ args[key] = true;
52
+ } else {
53
+ args[key] = next;
54
+ i += 1;
55
+ }
56
+ } else {
57
+ args._.push(value);
58
+ }
59
+ }
60
+ return args;
61
+ }
62
+
63
+ async function main() {
64
+ const argv = process.argv.slice(2);
65
+ const first = argv[0];
66
+ const command = first === "mcp" || argv.length === 0 ? "mcp" : first && !first.startsWith("--") ? first : "help";
67
+ const args = parseArgs(command === "mcp" ? argv.slice(first === "mcp" ? 1 : 0) : argv);
68
+
69
+ if (args.help || command === "help") {
70
+ printHelp();
71
+ return;
72
+ }
73
+
74
+ const options = resolveOptions(args);
75
+ if (command === "auth") {
76
+ await runAuth(options);
77
+ } else if (command === "install") {
78
+ await install(options, args);
79
+ } else if (command === "status") {
80
+ const tokens = loadTokens(options);
81
+ if (!tokens) {
82
+ console.log("Not authorized. Run: vxtalent-hr-cli auth --issuer <url>");
83
+ return;
84
+ }
85
+ const expiresAt = new Date(tokens.expires_at);
86
+ console.log(`Issuer: ${tokens.issuer}`);
87
+ console.log(`Client: ${tokens.client_id}`);
88
+ console.log(`Expires: ${expiresAt.toISOString()}`);
89
+ console.log(`Status: ${expiresAt.getTime() > Date.now() ? "valid" : "needs refresh"}`);
90
+ } else if (command === "logout") {
91
+ await revokeTokens(options);
92
+ clearTokens(options);
93
+ console.log("Local authorization removed.");
94
+ } else if (command === "mcp") {
95
+ await startMcpServer(options);
96
+ } else {
97
+ console.error(`Unknown command: ${command}`);
98
+ printHelp();
99
+ process.exitCode = 1;
100
+ }
101
+ }
102
+
103
+ main().catch((error) => {
104
+ console.error(`[vxtalent-hr-cli] ${error.message}`);
105
+ process.exit(1);
106
+ });
package/lib/auth.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import { saveTokens } from "./token-manager.mjs";
5
+
6
+ function randomBase64Url(size) {
7
+ return crypto.randomBytes(size).toString("base64url");
8
+ }
9
+
10
+ function createPkce() {
11
+ const verifier = randomBase64Url(48);
12
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
13
+ return { verifier, challenge };
14
+ }
15
+
16
+ function openBrowser(url) {
17
+ return new Promise((resolve) => {
18
+ const platform = process.platform;
19
+ let child;
20
+ if (platform === "win32") {
21
+ // Keep the URL quoted: cmd interprets an unquoted & as a command separator,
22
+ // which truncates OAuth query parameters such as client_id and redirect_uri.
23
+ child = spawn("cmd.exe", ["/d", "/s", "/c", `start "" "${url}"`], {
24
+ detached: true,
25
+ stdio: "ignore",
26
+ windowsVerbatimArguments: true,
27
+ });
28
+ } else {
29
+ const command = platform === "darwin" ? "open" : "xdg-open";
30
+ child = spawn(command, [url], { detached: true, stdio: "ignore", shell: false });
31
+ }
32
+ child.once("error", () => resolve(false));
33
+ child.once("spawn", () => {
34
+ child.unref();
35
+ resolve(true);
36
+ });
37
+ });
38
+ }
39
+
40
+ function htmlPage(title, message) {
41
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
42
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f3f6f8;color:#182230;font-family:"Segoe UI","Microsoft YaHei",sans-serif}main{width:min(420px,90vw);background:#fff;border:1px solid #dbe2ea;border-radius:8px;padding:28px}h1{font-size:22px;margin:0 0 10px}p{color:#65758a;line-height:1.6;margin:0}</style>
43
+ </head><body><main><h1>${title}</h1><p>${message}</p></main></body></html>`;
44
+ }
45
+
46
+ async function exchangeCode(options, code, redirectUri, verifier) {
47
+ const response = await fetch(`${options.issuer}/oauth/token`, {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
50
+ body: new URLSearchParams({
51
+ grant_type: "authorization_code",
52
+ code,
53
+ redirect_uri: redirectUri,
54
+ client_id: options.clientId,
55
+ code_verifier: verifier,
56
+ }),
57
+ signal: AbortSignal.timeout(30000),
58
+ });
59
+ const payload = await response.json().catch(() => ({}));
60
+ if (!response.ok) {
61
+ throw new Error(payload.error_description || `Token exchange failed (${response.status})`);
62
+ }
63
+ return payload;
64
+ }
65
+
66
+ export async function runAuth(options) {
67
+ const { verifier, challenge } = createPkce();
68
+ const state = randomBase64Url(24);
69
+
70
+ const server = http.createServer();
71
+ const callbackResult = new Promise((resolve, reject) => {
72
+ server.once("error", reject);
73
+ server.on("request", async (request, response) => {
74
+ const url = new URL(request.url || "/", "http://127.0.0.1");
75
+ if (url.pathname !== "/callback") {
76
+ response.writeHead(404).end();
77
+ return;
78
+ }
79
+ const code = url.searchParams.get("code") || "";
80
+ const returnedState = url.searchParams.get("state") || "";
81
+ const oauthError = url.searchParams.get("error") || "";
82
+ try {
83
+ if (oauthError) throw new Error(url.searchParams.get("error_description") || oauthError);
84
+ if (returnedState !== state) throw new Error("OAuth state mismatch");
85
+ if (!code) throw new Error("Authorization code is missing");
86
+ const tokens = await exchangeCode(options, code, redirectUri, verifier);
87
+ saveTokens(options, tokens);
88
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
89
+ response.end(htmlPage("授权成功", "vxTalent HR 授权完成,可以关闭此页面。"));
90
+ resolve(tokens);
91
+ } catch (error) {
92
+ response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
93
+ response.end(htmlPage("授权失败", error.message));
94
+ reject(error);
95
+ } finally {
96
+ server.close();
97
+ }
98
+ });
99
+ });
100
+
101
+ await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject));
102
+ const port = server.address().port;
103
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
104
+ const authorizeUrl = new URL(`${options.issuer}/oauth/authorize`);
105
+ authorizeUrl.searchParams.set("response_type", "code");
106
+ authorizeUrl.searchParams.set("client_id", options.clientId);
107
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
108
+ authorizeUrl.searchParams.set("scope", options.scope);
109
+ authorizeUrl.searchParams.set("state", state);
110
+ authorizeUrl.searchParams.set("code_challenge", challenge);
111
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
112
+
113
+ const timeout = setTimeout(() => {
114
+ server.close();
115
+ reject(new Error("Authorization timed out after 5 minutes."));
116
+ }, 5 * 60 * 1000);
117
+
118
+ console.error(`Opening authorization page: ${authorizeUrl.toString()}`);
119
+ const opened = await openBrowser(authorizeUrl.toString());
120
+ if (!opened) console.error("Could not open the browser automatically. Copy the URL above into a browser.");
121
+
122
+ try {
123
+ const tokens = await callbackResult;
124
+ clearTimeout(timeout);
125
+ console.error(`Authorized successfully. Token expires in ${tokens.expires_in} seconds.`);
126
+ } finally {
127
+ clearTimeout(timeout);
128
+ server.close();
129
+ }
130
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import path from "node:path";
2
+
3
+ export const DEFAULT_CLIENT_ID = "vxtalent-hr-cli";
4
+ export const DEFAULT_SCOPE = "hr:read";
5
+
6
+ function normalizeBase(value) {
7
+ const url = new URL(value);
8
+ url.hash = "";
9
+ url.search = "";
10
+ return url.toString().replace(/\/$/, "");
11
+ }
12
+
13
+ export function resolveOptions(args = {}) {
14
+ const issuer = args.issuer || process.env.VXTALENT_ISSUER || "";
15
+ if (!issuer) {
16
+ throw new Error("Missing issuer. Pass --issuer <url> or set VXTALENT_ISSUER.");
17
+ }
18
+ const normalizedIssuer = normalizeBase(issuer);
19
+ return {
20
+ issuer: normalizedIssuer,
21
+ clientId: args["client-id"] || process.env.VXTALENT_CLIENT_ID || DEFAULT_CLIENT_ID,
22
+ scope: args.scope || process.env.VXTALENT_SCOPE || DEFAULT_SCOPE,
23
+ mcpUrl:
24
+ args["mcp-url"] ||
25
+ process.env.VXTALENT_MCP_URL ||
26
+ `${normalizedIssuer}/mcp`,
27
+ autoAuth: args["no-auto-auth"] ? false : true,
28
+ };
29
+ }
30
+
31
+ export function platformConfigPath(platform, home) {
32
+ if (platform === "workbuddy") return path.join(home, ".workbuddy", "mcp.json");
33
+ if (platform === "qwen-work") return path.join(home, ".qwen-work", "mcp.json");
34
+ if (platform === "doubao-work") return path.join(home, ".doubao-work", "mcp.json");
35
+ return null;
36
+ }
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { platformConfigPath } from "./config.mjs";
6
+ import { runAuth } from "./auth.mjs";
7
+
8
+ function mcpConfig(options, localScript) {
9
+ if (localScript) {
10
+ // Local checkout mode: point directly at the local Node executable and
11
+ // script so agents can use the CLI before it is published to npm.
12
+ return {
13
+ type: "stdio",
14
+ command: process.execPath,
15
+ args: [localScript, "mcp"],
16
+ env: {
17
+ VXTALENT_ISSUER: options.issuer,
18
+ VXTALENT_MCP_URL: options.mcpUrl,
19
+ VXTALENT_CLIENT_ID: options.clientId,
20
+ },
21
+ };
22
+ }
23
+ return {
24
+ type: "stdio",
25
+ command: "npx",
26
+ args: ["-y", "vxtalent-hr-cli", "mcp"],
27
+ env: {
28
+ VXTALENT_ISSUER: options.issuer,
29
+ VXTALENT_MCP_URL: options.mcpUrl,
30
+ VXTALENT_CLIENT_ID: options.clientId,
31
+ },
32
+ };
33
+ }
34
+
35
+ export async function install(options, args) {
36
+ const platform = args.platform || "custom";
37
+ const configPath = args.config || platformConfigPath(platform, os.homedir());
38
+ const useLocal = Boolean(args["local"] ?? args["node"]);
39
+ const localScript = useLocal ? fileURLToPath(new URL("../index.mjs", import.meta.url)) : "";
40
+ const entry = mcpConfig(options, localScript);
41
+ let config = { mcpServers: {} };
42
+
43
+ if (args.write) {
44
+ if (!configPath) throw new Error("Pass --config <path> when using --write without a known platform.");
45
+ if (fs.existsSync(configPath)) {
46
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
47
+ if (typeof config !== "object" || config === null) throw new Error("Existing agent config is not a JSON object.");
48
+ config.mcpServers = config.mcpServers && typeof config.mcpServers === "object" ? config.mcpServers : {};
49
+ }
50
+ config.mcpServers["vxtalent-hr"] = entry;
51
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
52
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
53
+ console.log(`Wrote MCP config: ${configPath}`);
54
+ } else {
55
+ console.log("Agent MCP config (copy this JSON):");
56
+ console.log(JSON.stringify({ mcpServers: { "vxtalent-hr": entry } }, null, 2));
57
+ }
58
+
59
+ if (configPath && !args.write) {
60
+ console.log(`Suggested config path: ${configPath}`);
61
+ }
62
+ if (args.auth) {
63
+ console.error("Starting authorization...");
64
+ await runAuth(options);
65
+ }
66
+ }
@@ -0,0 +1,203 @@
1
+ import readline from "node:readline";
2
+ import { clearTokens, getValidAccessToken, loadTokens, revokeTokens } from "./token-manager.mjs";
3
+ import { runAuth } from "./auth.mjs";
4
+
5
+ function writeMessage(message) {
6
+ process.stdout.write(`${JSON.stringify(message)}\n`);
7
+ }
8
+
9
+ function errorResponse(id, code, message) {
10
+ return {
11
+ jsonrpc: "2.0",
12
+ id,
13
+ error: { code, message },
14
+ };
15
+ }
16
+
17
+ // Local-only tools injected into tools/list so users can manage the CLI
18
+ // authorization directly from the agent chat (no terminal required).
19
+ const LOCAL_TOOLS = [
20
+ {
21
+ name: "auth_status",
22
+ description: "查看 vxTalent HR 本机授权状态(是否已登录、token 过期时间)。",
23
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
24
+ },
25
+ {
26
+ name: "auth_logout",
27
+ description:
28
+ "退出 vxTalent HR 登录:撤销本机 token 并删除本地授权记录。调用后需要重新授权才能继续使用 HR 工具。",
29
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
30
+ },
31
+ ];
32
+
33
+ function mergeLocalTools(response, message) {
34
+ if (message.method === "tools/list" && response.result && Array.isArray(response.result.tools)) {
35
+ const names = new Set(response.result.tools.map((tool) => tool.name));
36
+ const extra = LOCAL_TOOLS.filter((tool) => !names.has(tool.name));
37
+ if (extra.length > 0) {
38
+ response.result.tools = [...response.result.tools, ...extra];
39
+ }
40
+ }
41
+ return response;
42
+ }
43
+
44
+ function parseSseBody(body) {
45
+ const messages = [];
46
+ let data = [];
47
+ for (const line of body.split(/\r?\n/)) {
48
+ if (line === "") {
49
+ if (data.length > 0) {
50
+ try {
51
+ messages.push(JSON.parse(data.join("\n")));
52
+ } catch {
53
+ // Ignore keep-alive comments and non-JSON events.
54
+ }
55
+ }
56
+ data = [];
57
+ } else if (line.startsWith("data:")) {
58
+ data.push(line.slice(5).trimStart());
59
+ }
60
+ }
61
+ if (data.length > 0) {
62
+ try {
63
+ messages.push(JSON.parse(data.join("\n")));
64
+ } catch {
65
+ // Ignore incomplete event data.
66
+ }
67
+ }
68
+ return messages;
69
+ }
70
+
71
+ async function postToRemote(options, message, sessionId, ensureToken) {
72
+ const accessToken = await ensureToken();
73
+ const headers = {
74
+ "Content-Type": "application/json",
75
+ Accept: "application/json, text/event-stream",
76
+ Authorization: `Bearer ${accessToken}`,
77
+ };
78
+ if (sessionId) headers["Mcp-Session-Id"] = sessionId;
79
+
80
+ const response = await fetch(options.mcpUrl, {
81
+ method: "POST",
82
+ headers,
83
+ body: JSON.stringify(message),
84
+ signal: AbortSignal.timeout(120000),
85
+ });
86
+ const text = await response.text();
87
+ if (!response.ok) {
88
+ const detail = text.slice(0, 500);
89
+ throw new Error(`Remote MCP returned ${response.status}: ${detail}`);
90
+ }
91
+
92
+ const contentType = response.headers.get("content-type") || "";
93
+ const messages = contentType.includes("text/event-stream")
94
+ ? parseSseBody(text)
95
+ : text.trim()
96
+ ? [JSON.parse(text)]
97
+ : [];
98
+ return { messages, sessionId: response.headers.get("mcp-session-id") || sessionId };
99
+ }
100
+
101
+ export async function startMcpServer(options) {
102
+ let sessionId = "";
103
+ let initialized = false;
104
+ let authTried = false;
105
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
106
+ let queue = Promise.resolve();
107
+
108
+ console.error("[vxtalent-hr-cli] MCP stdio bridge started. Waiting for JSON-RPC messages on stdin...");
109
+
110
+ async function ensureToken() {
111
+ try {
112
+ return await getValidAccessToken(options);
113
+ } catch (error) {
114
+ if (options.autoAuth && !authTried) {
115
+ authTried = true;
116
+ console.error("[vxtalent-hr-cli] No valid token; starting browser authorization...");
117
+ await runAuth(options);
118
+ return await getValidAccessToken(options);
119
+ }
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ async function handleLocalTool(message, name) {
125
+ try {
126
+ let text;
127
+ if (name === "auth_logout") {
128
+ await revokeTokens(options);
129
+ clearTokens(options);
130
+ text = "已退出登录,本机授权已撤销。如需继续使用 HR 工具,请重新授权。";
131
+ } else {
132
+ const tokens = loadTokens(options);
133
+ text = tokens
134
+ ? `已登录。token 过期时间:${new Date(tokens.expires_at).toISOString()}`
135
+ : "未登录。需要先完成授权才能使用 HR 工具。";
136
+ }
137
+ writeMessage({
138
+ jsonrpc: "2.0",
139
+ id: message.id,
140
+ result: { content: [{ type: "text", text }] },
141
+ });
142
+ } catch (error) {
143
+ writeMessage(errorResponse(message.id, -32603, error.message));
144
+ }
145
+ }
146
+
147
+ rl.on("line", async (line) => {
148
+ if (!line.trim()) return;
149
+ let message;
150
+ try {
151
+ message = JSON.parse(line);
152
+ } catch {
153
+ return;
154
+ }
155
+
156
+ queue = queue.then(async () => {
157
+ const isNotification = message.id === undefined;
158
+ const isInitialize = message.method === "initialize";
159
+ // Local tools are handled here so users can log out from the chat.
160
+ if (message.method === "tools/call" && message.params?.name === "auth_logout") {
161
+ await handleLocalTool(message, "auth_logout");
162
+ return;
163
+ }
164
+ if (message.method === "tools/call" && message.params?.name === "auth_status") {
165
+ await handleLocalTool(message, "auth_status");
166
+ return;
167
+ }
168
+ try {
169
+ const result = await postToRemote(options, message, sessionId, ensureToken);
170
+ if (isInitialize && result.sessionId) {
171
+ sessionId = result.sessionId;
172
+ }
173
+ if (isInitialize) {
174
+ initialized = true;
175
+ }
176
+ for (const response of result.messages) {
177
+ // The remote replies to initialize with the server info; it may also
178
+ // emit a "notifications/initialized" notification (no id). Notifications
179
+ // must not be forwarded as responses.
180
+ if (response.id === undefined && response.method === "notifications/initialized") {
181
+ continue;
182
+ }
183
+ writeMessage(mergeLocalTools(response, message));
184
+ }
185
+ } catch (error) {
186
+ if (!isNotification) {
187
+ const code = error.message.includes("Not authorized") ? 401 : -32603;
188
+ writeMessage(errorResponse(message.id, code, error.message));
189
+ } else {
190
+ console.error(`[vxtalent-hr-cli] ${error.message}`);
191
+ }
192
+ if (error.message.includes("Not authorized")) {
193
+ console.error(`[vxtalent-hr-cli] Authorization missing or expired. Run: vxtalent-hr-cli auth --issuer ${options.issuer}`);
194
+ }
195
+ }
196
+ });
197
+ });
198
+
199
+ await new Promise((resolve) => rl.once("close", resolve));
200
+ // Drain any in-flight request before exiting (stdin may close right after
201
+ // the last piped message while the remote call is still pending).
202
+ await queue;
203
+ }
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const TOKEN_BUFFER_MS = 5 * 60 * 1000;
6
+
7
+ function authFile(options) {
8
+ return path.join(os.homedir(), ".vxtalent", "auth.json");
9
+ }
10
+
11
+ export function loadTokens(options) {
12
+ const file = authFile(options);
13
+ if (!fs.existsSync(file)) return null;
14
+ try {
15
+ const tokens = JSON.parse(fs.readFileSync(file, "utf8"));
16
+ if (tokens.issuer !== options.issuer) return null;
17
+ return tokens;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function saveTokens(options, tokens) {
24
+ const file = authFile(options);
25
+ fs.mkdirSync(path.dirname(file), { recursive: true });
26
+ const record = {
27
+ version: 1,
28
+ issuer: options.issuer,
29
+ client_id: options.clientId,
30
+ scope: tokens.scope || options.scope,
31
+ access_token: tokens.access_token,
32
+ refresh_token: tokens.refresh_token || "",
33
+ expires_at: Date.now() + Number(tokens.expires_in || 0) * 1000,
34
+ };
35
+ const tempFile = `${file}.${process.pid}.tmp`;
36
+ fs.writeFileSync(tempFile, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
37
+ fs.renameSync(tempFile, file);
38
+ fs.chmodSync(file, 0o600);
39
+ return record;
40
+ }
41
+
42
+ export function clearTokens(options) {
43
+ const file = authFile(options);
44
+ fs.rmSync(file, { force: true });
45
+ }
46
+
47
+ export async function getValidAccessToken(options) {
48
+ const tokens = loadTokens(options);
49
+ if (!tokens) {
50
+ throw new Error("Not authorized. Run: vxtalent-hr-cli auth --issuer <url>");
51
+ }
52
+ if (tokens.expires_at - TOKEN_BUFFER_MS > Date.now()) {
53
+ return tokens.access_token;
54
+ }
55
+ if (!tokens.refresh_token) {
56
+ throw new Error("Authorization expired and no refresh token exists. Re-run auth.");
57
+ }
58
+
59
+ const response = await fetch(`${options.issuer}/oauth/token`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
62
+ body: new URLSearchParams({
63
+ grant_type: "refresh_token",
64
+ refresh_token: tokens.refresh_token,
65
+ client_id: options.clientId,
66
+ }),
67
+ signal: AbortSignal.timeout(30000),
68
+ });
69
+ const payload = await response.json().catch(() => ({}));
70
+ if (!response.ok) {
71
+ clearTokens(options);
72
+ throw new Error(payload.error_description || "Token refresh failed. Re-run auth.");
73
+ }
74
+ const refreshed = saveTokens(options, { ...tokens, ...payload });
75
+ return refreshed.access_token;
76
+ }
77
+
78
+ export async function revokeTokens(options) {
79
+ const tokens = loadTokens(options);
80
+ if (!tokens) return;
81
+ try {
82
+ await fetch(`${options.issuer}/oauth/revoke`, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
85
+ body: new URLSearchParams({
86
+ token: tokens.access_token,
87
+ client_id: options.clientId,
88
+ }),
89
+ signal: AbortSignal.timeout(30000),
90
+ });
91
+ } catch (error) {
92
+ console.error(`[vxtalent-hr-cli] Revocation request failed: ${error.message}`);
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "vxtalent-hr-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "vxTalent HR MCP CLI for WorkBuddy, Qwen Work, Doubao Work and other MCP agents",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=18.17"
9
+ },
10
+ "bin": {
11
+ "vxtalent-hr-cli": "./index.mjs"
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "lib",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "mcp",
20
+ "oauth",
21
+ "hr",
22
+ "cli",
23
+ "workbuddy",
24
+ "qwen-work",
25
+ "doubao-work"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://your-git-host/vxTalent-AI/Backend-qwenagent.git"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
6
34
  }