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 +57 -0
- package/bin/workq-mcp.mjs +141 -0
- package/bin/workq.mjs +160 -0
- package/package.json +21 -0
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
|
+
}
|