workq-mcp 0.1.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 ADDED
@@ -0,0 +1,57 @@
1
+ # workq-mcp
2
+
3
+ Work Q MCP stdio bridge and local repository connection CLI.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g workq-mcp
9
+ ```
10
+
11
+ ## Binaries
12
+
13
+ - `workq-mcp`: stdio MCP bridge that forwards JSON-RPC to `https://work-q.nado.ai.kr/api/mcp`.
14
+ - `workq`: helper CLI for one-time local repository connection.
15
+
16
+ ## Environment
17
+
18
+ ```bash
19
+ WORKQ_MCP_URL=https://work-q.nado.ai.kr/api/mcp
20
+ WORKQ_MCP_TOKEN_FILE=~/.workq/mcp-token
21
+ # or
22
+ WORKQ_MCP_TOKEN=<token>
23
+ ```
24
+
25
+ ## Connect A Local Repo
26
+
27
+ Run this from the intended local git repository:
28
+
29
+ ```bash
30
+ workq connect --project-key WORKQ
31
+ ```
32
+
33
+ The CLI reads:
34
+
35
+ ```bash
36
+ git rev-parse --show-toplevel
37
+ git remote get-url origin
38
+ git branch --show-current
39
+ git rev-parse HEAD
40
+ ```
41
+
42
+ Then it calls `workq_connect_local_project`.
43
+
44
+ ## Codex MCP Registration
45
+
46
+ Example:
47
+
48
+ ```toml
49
+ [mcp_servers.work_q]
50
+ command = "/opt/homebrew/bin/workq-mcp"
51
+ startup_timeout_sec = 30
52
+ tool_timeout_sec = 300
53
+
54
+ [mcp_servers.work_q.env]
55
+ WORKQ_MCP_URL = "https://work-q.nado.ai.kr/api/mcp"
56
+ WORKQ_MCP_TOKEN_FILE = "/Users/jin/.codex/workq-mcp-token"
57
+ ```
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+
6
+ const endpoint = process.env.WORKQ_MCP_URL || "https://work-q.nado.ai.kr/api/mcp";
7
+ const tokenFile =
8
+ process.env.WORKQ_MCP_TOKEN_FILE ||
9
+ `${os.homedir()}/.workq/mcp-token`;
10
+
11
+ let buffer = Buffer.alloc(0);
12
+ let outputMode = "line";
13
+ let draining = Promise.resolve();
14
+ let ended = false;
15
+
16
+ function readToken() {
17
+ const directToken = process.env.WORKQ_MCP_TOKEN?.trim();
18
+ if (directToken) return directToken;
19
+ const token = fs.readFileSync(tokenFile, "utf8").trim();
20
+ if (!token) throw new Error(`Work Q MCP token file is empty: ${tokenFile}`);
21
+ return token;
22
+ }
23
+
24
+ function encodeMessage(message) {
25
+ const body = JSON.stringify(message);
26
+ if (outputMode === "headers") {
27
+ const length = Buffer.byteLength(body, "utf8");
28
+ return `Content-Length: ${length}\r\n\r\n${body}`;
29
+ }
30
+ return `${body}\n`;
31
+ }
32
+
33
+ function send(message) {
34
+ process.stdout.write(encodeMessage(message));
35
+ }
36
+
37
+ function sendError(id, code, message) {
38
+ send({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
39
+ }
40
+
41
+ async function forward(message) {
42
+ try {
43
+ const response = await fetch(endpoint, {
44
+ method: "POST",
45
+ headers: {
46
+ authorization: `Bearer ${readToken()}`,
47
+ "content-type": "application/json",
48
+ accept: "application/json"
49
+ },
50
+ body: JSON.stringify(message)
51
+ });
52
+
53
+ if (response.status === 204) return;
54
+
55
+ const text = await response.text();
56
+ let payload = null;
57
+ if (text) {
58
+ try {
59
+ payload = JSON.parse(text);
60
+ } catch {
61
+ throw new Error(`Work Q MCP returned non-JSON response: ${text.slice(0, 300)}`);
62
+ }
63
+ }
64
+
65
+ if (!response.ok) {
66
+ const detail = payload?.error ?? response.statusText;
67
+ throw new Error(`Work Q MCP HTTP ${response.status}: ${detail}`);
68
+ }
69
+
70
+ if (payload) send(payload);
71
+ } catch (error) {
72
+ sendError(message?.id, -32000, error instanceof Error ? error.message : String(error));
73
+ }
74
+ }
75
+
76
+ function parseHeaderMessage() {
77
+ const headerEnd = buffer.indexOf("\r\n\r\n");
78
+ if (headerEnd === -1) return null;
79
+
80
+ const header = buffer.subarray(0, headerEnd).toString("utf8");
81
+ const match = header.match(/content-length:\s*(\d+)/i);
82
+ if (!match) throw new Error("Missing Content-Length header");
83
+
84
+ const length = Number(match[1]);
85
+ const bodyStart = headerEnd + 4;
86
+ const bodyEnd = bodyStart + length;
87
+ if (buffer.length < bodyEnd) return null;
88
+
89
+ const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
90
+ buffer = buffer.subarray(bodyEnd);
91
+ outputMode = "headers";
92
+ return JSON.parse(body);
93
+ }
94
+
95
+ function parseLineMessage() {
96
+ const newline = buffer.indexOf("\n");
97
+ if (newline === -1) return null;
98
+
99
+ const line = buffer.subarray(0, newline).toString("utf8").trim();
100
+ buffer = buffer.subarray(newline + 1);
101
+ outputMode = "line";
102
+ return line ? JSON.parse(line) : null;
103
+ }
104
+
105
+ async function drain() {
106
+ while (buffer.length) {
107
+ let message = null;
108
+ try {
109
+ const prefix = buffer.toString("utf8", 0, Math.min(buffer.length, 32)).toLowerCase();
110
+ message = prefix.startsWith("content-length:") ? parseHeaderMessage() : parseLineMessage();
111
+ } catch (error) {
112
+ sendError(null, -32700, error instanceof Error ? error.message : String(error));
113
+ buffer = Buffer.alloc(0);
114
+ return;
115
+ }
116
+
117
+ if (!message) return;
118
+ await forward(message);
119
+ }
120
+ }
121
+
122
+ function scheduleDrain() {
123
+ draining = draining
124
+ .then(() => drain())
125
+ .catch((error) => {
126
+ sendError(null, -32000, error instanceof Error ? error.message : String(error));
127
+ })
128
+ .then(() => {
129
+ if (ended && buffer.length === 0) process.exit(0);
130
+ });
131
+ }
132
+
133
+ process.stdin.on("data", (chunk) => {
134
+ buffer = Buffer.concat([buffer, chunk]);
135
+ scheduleDrain();
136
+ });
137
+
138
+ process.stdin.on("end", () => {
139
+ ended = true;
140
+ scheduleDrain();
141
+ });
package/bin/workq.mjs ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+
7
+ const DEFAULT_ENDPOINT = "https://work-q.nado.ai.kr/api/mcp";
8
+
9
+ function usage() {
10
+ process.stdout.write(`Work Q CLI
11
+
12
+ Usage:
13
+ workq connect --project-key <KEY> [--device-label <LABEL>]
14
+ workq mcp
15
+
16
+ Environment:
17
+ WORKQ_MCP_URL Defaults to ${DEFAULT_ENDPOINT}
18
+ WORKQ_MCP_TOKEN Direct MCP bearer token
19
+ WORKQ_MCP_TOKEN_FILE Token file path, defaults to ~/.workq/mcp-token
20
+
21
+ Examples:
22
+ workq connect --project-key WORKQ
23
+ WORKQ_MCP_TOKEN_FILE=~/.codex/workq-mcp-token workq connect --project-key WORKQ
24
+ `);
25
+ }
26
+
27
+ function parseArgs(argv) {
28
+ const args = { _: [] };
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const item = argv[index];
31
+ if (!item.startsWith("--")) {
32
+ args._.push(item);
33
+ continue;
34
+ }
35
+ const key = item.slice(2);
36
+ const next = argv[index + 1];
37
+ if (!next || next.startsWith("--")) {
38
+ args[key] = true;
39
+ continue;
40
+ }
41
+ args[key] = next;
42
+ index += 1;
43
+ }
44
+ return args;
45
+ }
46
+
47
+ function required(value, name) {
48
+ if (typeof value !== "string" || !value.trim()) {
49
+ throw new Error(`${name} is required`);
50
+ }
51
+ return value.trim();
52
+ }
53
+
54
+ function tokenFile() {
55
+ return process.env.WORKQ_MCP_TOKEN_FILE || `${os.homedir()}/.workq/mcp-token`;
56
+ }
57
+
58
+ function readToken() {
59
+ const directToken = process.env.WORKQ_MCP_TOKEN?.trim();
60
+ if (directToken) return directToken;
61
+ const file = tokenFile();
62
+ const token = fs.readFileSync(file, "utf8").trim();
63
+ if (!token) throw new Error(`Work Q MCP token file is empty: ${file}`);
64
+ return token;
65
+ }
66
+
67
+ function git(args) {
68
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
69
+ }
70
+
71
+ function parseRemote(remote) {
72
+ const ssh = remote.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
73
+ if (ssh) return { owner: ssh[1], repo: ssh[2].replace(/\.git$/i, "") };
74
+ const url = new URL(remote);
75
+ if (url.hostname.toLowerCase() !== "github.com") throw new Error(`Only github.com remotes are supported: ${remote}`);
76
+ const [owner, repo] = url.pathname.replace(/^\/+/, "").split("/");
77
+ if (!owner || !repo) throw new Error(`Could not parse GitHub remote: ${remote}`);
78
+ return { owner, repo: repo.replace(/\.git$/i, "") };
79
+ }
80
+
81
+ async function callMcp(name, argumentsPayload) {
82
+ const endpoint = process.env.WORKQ_MCP_URL || DEFAULT_ENDPOINT;
83
+ const response = await fetch(endpoint, {
84
+ method: "POST",
85
+ headers: {
86
+ authorization: `Bearer ${readToken()}`,
87
+ "content-type": "application/json",
88
+ accept: "application/json"
89
+ },
90
+ body: JSON.stringify({
91
+ jsonrpc: "2.0",
92
+ id: `${name}-${Date.now()}`,
93
+ method: "tools/call",
94
+ params: {
95
+ name,
96
+ arguments: argumentsPayload
97
+ }
98
+ })
99
+ });
100
+
101
+ const text = await response.text();
102
+ const payload = text ? JSON.parse(text) : null;
103
+ if (!response.ok || payload?.error) {
104
+ throw new Error(payload?.error?.message || payload?.error || response.statusText);
105
+ }
106
+ return payload.result.structuredContent;
107
+ }
108
+
109
+ async function connect(args) {
110
+ const projectKey = required(args["project-key"] || args.project_key, "--project-key");
111
+ const localPath = git(["rev-parse", "--show-toplevel"]);
112
+ const gitRemoteUrl = git(["remote", "get-url", "origin"]);
113
+ const gitBranch = git(["branch", "--show-current"]);
114
+ const commitSha = git(["rev-parse", "HEAD"]);
115
+ const remote = parseRemote(gitRemoteUrl);
116
+ const deviceLabel = typeof args["device-label"] === "string" ? args["device-label"].trim() : `${os.hostname()} local`;
117
+
118
+ const result = await callMcp("workq_connect_local_project", {
119
+ project_key: projectKey,
120
+ device_label: deviceLabel,
121
+ local_path: localPath,
122
+ git_remote_url: gitRemoteUrl,
123
+ repository_owner: remote.owner,
124
+ repository_repo: remote.repo,
125
+ git_branch: gitBranch,
126
+ commit_sha: commitSha
127
+ });
128
+
129
+ process.stdout.write(`Connected Work Q local repository binding.
130
+ project_key: ${result.service.project_key}
131
+ binding_id: ${result.binding.id}
132
+ status: ${result.binding.status}
133
+ repo: ${result.binding.repository_full_name}
134
+ local_path: ${result.binding.local_path}
135
+ branch: ${result.binding.git_branch ?? ""}
136
+ `);
137
+ }
138
+
139
+ async function main() {
140
+ const args = parseArgs(process.argv.slice(2));
141
+ const command = args._[0];
142
+ if (!command || args.help || args.h) {
143
+ usage();
144
+ return;
145
+ }
146
+ if (command === "connect") {
147
+ await connect(args);
148
+ return;
149
+ }
150
+ if (command === "mcp") {
151
+ await import("./workq-mcp.mjs");
152
+ return;
153
+ }
154
+ throw new Error(`Unknown command: ${command}`);
155
+ }
156
+
157
+ main().catch((error) => {
158
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
159
+ process.exit(1);
160
+ });
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "workq-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Work Q MCP stdio bridge and local repository connection CLI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "workq-mcp": "bin/workq-mcp.mjs",
8
+ "workq": "bin/workq.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20.0.0"
16
+ },
17
+ "license": "UNLICENSED",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ }
21
+ }