@zegazone_mcp/mcp 2.0.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 +123 -0
- package/dist/api.js +159 -0
- package/dist/config.js +47 -0
- package/dist/credentials-types.js +1 -0
- package/dist/index.js +44 -0
- package/dist/oauth-client.js +67 -0
- package/dist/ops.js +100 -0
- package/dist/server.js +159 -0
- package/dist/token-provider.js +93 -0
- package/dist/token-store.js +48 -0
- package/dist/tool-metadata.js +128 -0
- package/dist/tool-schemas.js +146 -0
- package/package.json +37 -0
- package/scripts/oauth-pair.mjs +226 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One-time OAuth PKCE pairing: opens the Zegazone app, receives the authorization
|
|
4
|
+
* code on a loopback callback, exchanges it for access + refresh tokens, and
|
|
5
|
+
* writes ~/.zegazone-mcp/credentials.json (or ZEGA_CREDENTIALS_PATH).
|
|
6
|
+
*
|
|
7
|
+
* Env:
|
|
8
|
+
* ZEGA_API_BASE (required) e.g. https://api.zegaphone.com
|
|
9
|
+
* ZEGA_APP_BASE optional, default https://www.zegazone.com
|
|
10
|
+
* ZEGA_OAUTH_CLIENT_ID optional, default openclaw
|
|
11
|
+
* ZEGA_CREDENTIALS_PATH optional override for the JSON file path
|
|
12
|
+
* ZEGA_TIMEOUT_MS optional, default 30000
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
const apiBase = (process.env.ZEGA_API_BASE ?? "").trim().replace(/\/+$/, "");
|
|
23
|
+
// Production web app is deployed at www (app subdomain may be unset on Vercel → DEPLOYMENT_NOT_FOUND).
|
|
24
|
+
const appBase = (process.env.ZEGA_APP_BASE ?? "https://www.zegazone.com").trim().replace(/\/+$/, "");
|
|
25
|
+
const clientId = (process.env.ZEGA_OAUTH_CLIENT_ID ?? "openclaw").trim() || "openclaw";
|
|
26
|
+
const timeoutMs = Number(process.env.ZEGA_TIMEOUT_MS ?? "30000") || 30000;
|
|
27
|
+
|
|
28
|
+
const SCOPE = "collections:read collections:write media:read media:write";
|
|
29
|
+
|
|
30
|
+
function credentialsPath() {
|
|
31
|
+
const e = process.env.ZEGA_CREDENTIALS_PATH?.trim();
|
|
32
|
+
return e ? path.resolve(e) : path.join(os.homedir(), ".zegazone-mcp", "credentials.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function base64url(buf) {
|
|
36
|
+
return buf
|
|
37
|
+
.toString("base64")
|
|
38
|
+
.replace(/\+/g, "-")
|
|
39
|
+
.replace(/\//g, "_")
|
|
40
|
+
.replace(/=+$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function randomVerifier() {
|
|
44
|
+
return base64url(crypto.randomBytes(32));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function challengeS256(verifier) {
|
|
48
|
+
return base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function randomState() {
|
|
52
|
+
return base64url(crypto.randomBytes(16));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function openBrowser(url) {
|
|
56
|
+
const plat = process.platform;
|
|
57
|
+
const detached = { detached: true, stdio: "ignore" };
|
|
58
|
+
if (plat === "win32") {
|
|
59
|
+
// Avoid cmd.exe `start`: `&` in the URL is treated as a command separator and the browser
|
|
60
|
+
// opens a truncated link (often only ?oauth=1), causing "Invalid OAuth request" on the app.
|
|
61
|
+
spawn("rundll32", ["url.dll,FileProtocolHandler", url], detached).unref();
|
|
62
|
+
} else if (plat === "darwin") {
|
|
63
|
+
spawn("open", [url], detached).unref();
|
|
64
|
+
} else {
|
|
65
|
+
spawn("xdg-open", [url], detached).unref();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function postToken(body) {
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${apiBase}/functions/v1/thirdparty-oauth-token`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body,
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
});
|
|
79
|
+
const parsed = await res.json().catch(() => ({}));
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw new Error(`Token exchange failed (${res.status}): ${JSON.stringify(parsed)}`);
|
|
82
|
+
}
|
|
83
|
+
const access_token = typeof parsed.access_token === "string" ? parsed.access_token : "";
|
|
84
|
+
const refresh_token = typeof parsed.refresh_token === "string" ? parsed.refresh_token : "";
|
|
85
|
+
const expires_in = typeof parsed.expires_in === "number" ? parsed.expires_in : 3600;
|
|
86
|
+
if (!access_token || !refresh_token) {
|
|
87
|
+
throw new Error(`Token response missing tokens: ${JSON.stringify(parsed)}`);
|
|
88
|
+
}
|
|
89
|
+
return { access_token, refresh_token, expires_in };
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(t);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function writeCredentials(filePath, tokens) {
|
|
96
|
+
const access_expires_at_ms = Date.now() + Math.max(0, tokens.expires_in) * 1000;
|
|
97
|
+
const doc = {
|
|
98
|
+
version: 1,
|
|
99
|
+
client_id: clientId,
|
|
100
|
+
refresh_token: tokens.refresh_token,
|
|
101
|
+
access_token: tokens.access_token,
|
|
102
|
+
access_expires_at_ms,
|
|
103
|
+
};
|
|
104
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
105
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
106
|
+
await fs.writeFile(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
107
|
+
await fs.rename(tmp, filePath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildAuthorizeUrl(redirectUri, codeChallenge, state) {
|
|
111
|
+
const u = new URL(`${appBase}/`);
|
|
112
|
+
u.searchParams.set("oauth", "1");
|
|
113
|
+
u.searchParams.set("client_id", clientId);
|
|
114
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
115
|
+
u.searchParams.set("code_challenge", codeChallenge);
|
|
116
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
117
|
+
u.searchParams.set("scope", SCOPE);
|
|
118
|
+
u.searchParams.set("state", state);
|
|
119
|
+
return u.toString();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
if (!apiBase) {
|
|
124
|
+
console.error("Set ZEGA_API_BASE (e.g. https://api.zegaphone.com)");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const verifier = randomVerifier();
|
|
129
|
+
const challenge = challengeS256(verifier);
|
|
130
|
+
const state = randomState();
|
|
131
|
+
const credPath = credentialsPath();
|
|
132
|
+
|
|
133
|
+
const server = http.createServer();
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
server.once("error", reject);
|
|
136
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const addr = server.address();
|
|
140
|
+
if (!addr || typeof addr === "string") {
|
|
141
|
+
console.error("Could not bind loopback server");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const redirectUri = `http://127.0.0.1:${addr.port}/callback`;
|
|
146
|
+
const authorizeUrl = buildAuthorizeUrl(redirectUri, challenge, state);
|
|
147
|
+
|
|
148
|
+
const done = new Promise((resolve, reject) => {
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
reject(new Error("Timed out waiting for browser callback (10 minutes)"));
|
|
151
|
+
}, 10 * 60_000);
|
|
152
|
+
|
|
153
|
+
server.on("request", async (req, res) => {
|
|
154
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${addr.port}`);
|
|
155
|
+
if (url.pathname !== "/callback") {
|
|
156
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
157
|
+
res.end("Not found");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const code = url.searchParams.get("code") ?? "";
|
|
162
|
+
const returnedState = url.searchParams.get("state") ?? "";
|
|
163
|
+
const err = url.searchParams.get("error");
|
|
164
|
+
|
|
165
|
+
if (err) {
|
|
166
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
167
|
+
res.end(`<p>Authorization error: ${err}</p><p>You can close this tab.</p>`);
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
server.close();
|
|
170
|
+
reject(new Error(`OAuth error: ${err}`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!code || returnedState !== state) {
|
|
175
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
176
|
+
res.end("<p>Invalid callback (missing code or state mismatch).</p>");
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
server.close();
|
|
179
|
+
reject(new Error("Invalid OAuth callback"));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const tokens = await postToken(
|
|
185
|
+
JSON.stringify({
|
|
186
|
+
grant_type: "authorization_code",
|
|
187
|
+
client_id: clientId,
|
|
188
|
+
code,
|
|
189
|
+
redirect_uri: redirectUri,
|
|
190
|
+
code_verifier: verifier,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
await writeCredentials(credPath, tokens);
|
|
194
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
195
|
+
res.end(
|
|
196
|
+
"<p>Connected. You can close this tab and return to the terminal.</p>",
|
|
197
|
+
);
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
server.close();
|
|
200
|
+
resolve(tokens);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
203
|
+
res.end("<p>Token exchange failed. See terminal output.</p>");
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
server.close();
|
|
206
|
+
reject(e);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
console.error("Opening browser for Zegazone login…");
|
|
212
|
+
console.error(`If it does not open, visit:\n${authorizeUrl}\n`);
|
|
213
|
+
openBrowser(authorizeUrl);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await done;
|
|
217
|
+
console.error(`Saved credentials to ${credPath}`);
|
|
218
|
+
console.error("You can start the MCP server; remove ZEGA_ACCESS_TOKEN from mcp.json if you still have it.");
|
|
219
|
+
} catch (e) {
|
|
220
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
221
|
+
console.error(msg);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main();
|