zerno-mcp 0.1.1 → 0.1.2
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 +2 -2
- package/dist/index.js +1 -1
- package/dist/setup.js +105 -122
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Local stdio MCP server for ZERNO code-agent integrations.
|
|
|
7
7
|
Recommended setup:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx -y zerno-mcp@0.1.
|
|
10
|
+
npx -y zerno-mcp@0.1.2 setup
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
The setup command opens ZERNO in the browser, lets the user choose a project,
|
|
@@ -16,7 +16,7 @@ creates the project-scoped MCP token, and writes local agent configs.
|
|
|
16
16
|
Agent runtime:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
npx -y zerno-mcp@0.1.
|
|
19
|
+
npx -y zerno-mcp@0.1.2
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Required environment:
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ if (process.argv[2] === "setup") {
|
|
|
22
22
|
process.exit(0);
|
|
23
23
|
}
|
|
24
24
|
const client = await import("./zerno-client.js");
|
|
25
|
-
const server = new Server({ name: "zerno-mcp", version: "0.1.
|
|
25
|
+
const server = new Server({ name: "zerno-mcp", version: "0.1.2" }, { capabilities: { tools: {} } });
|
|
26
26
|
function asRecord(value) {
|
|
27
27
|
if (value && typeof value === "object") {
|
|
28
28
|
return value;
|
package/dist/setup.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir,
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
|
-
import { createInterface } from "node:readline/promises";
|
|
7
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
8
7
|
const DEFAULT_API_URL = "https://zerno.one";
|
|
9
|
-
const PACKAGE_SPEC = "zerno-mcp@0.1.
|
|
8
|
+
const PACKAGE_SPEC = "zerno-mcp@0.1.2";
|
|
10
9
|
const MANAGED_START = "# ZERNO MCP:START";
|
|
11
10
|
const MANAGED_END = "# ZERNO MCP:END";
|
|
12
11
|
function argValue(args, name) {
|
|
@@ -17,39 +16,44 @@ function argValue(args, name) {
|
|
|
17
16
|
const idx = args.indexOf(name);
|
|
18
17
|
return idx >= 0 ? args[idx + 1] : undefined;
|
|
19
18
|
}
|
|
19
|
+
function b64url(input) {
|
|
20
|
+
return input.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
21
|
+
}
|
|
22
|
+
function newOpaque(bytes = 32) {
|
|
23
|
+
return b64url(randomBytes(bytes));
|
|
24
|
+
}
|
|
25
|
+
function pkceChallenge(verifier) {
|
|
26
|
+
return b64url(createHash("sha256").update(verifier).digest());
|
|
27
|
+
}
|
|
20
28
|
function openBrowser(url) {
|
|
21
29
|
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
22
30
|
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
|
|
23
31
|
spawnSync(command, args, { stdio: "ignore" });
|
|
24
32
|
}
|
|
25
|
-
function
|
|
33
|
+
function callbackServer() {
|
|
26
34
|
return new Promise((resolve, reject) => {
|
|
35
|
+
let done = () => { };
|
|
36
|
+
let fail = () => { };
|
|
37
|
+
const result = new Promise((ok, bad) => {
|
|
38
|
+
done = ok;
|
|
39
|
+
fail = bad;
|
|
40
|
+
});
|
|
27
41
|
const server = createServer((req, res) => {
|
|
28
42
|
const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
29
|
-
if (!requestUrl.pathname.startsWith("/
|
|
43
|
+
if (!requestUrl.pathname.startsWith("/zerno-mcp/callback")) {
|
|
30
44
|
res.writeHead(404).end("Not found");
|
|
31
45
|
return;
|
|
32
46
|
}
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
? "<h1>ZERNO MCP connected</h1><p>You can close this tab and return to the terminal.</p>"
|
|
39
|
-
: "<h1>ZERNO MCP setup failed</h1><p>Missing login token.</p>");
|
|
47
|
+
const error = requestUrl.searchParams.get("error") || undefined;
|
|
48
|
+
const code = requestUrl.searchParams.get("code") || "";
|
|
49
|
+
const state = requestUrl.searchParams.get("state") || "";
|
|
50
|
+
const ok = Boolean(code) && !error;
|
|
51
|
+
res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" }).end(successHtml(ok, error));
|
|
40
52
|
server.close();
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
tokenReject(new Error("Browser callback did not include desktopToken"));
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
let tokenResolve = () => { };
|
|
49
|
-
let tokenReject = () => { };
|
|
50
|
-
const token = new Promise((ok, fail) => {
|
|
51
|
-
tokenResolve = ok;
|
|
52
|
-
tokenReject = fail;
|
|
53
|
+
if (ok)
|
|
54
|
+
done({ code, state });
|
|
55
|
+
else
|
|
56
|
+
fail(new Error(error || "OAuth callback did not include code"));
|
|
53
57
|
});
|
|
54
58
|
server.on("error", reject);
|
|
55
59
|
server.listen(0, "127.0.0.1", () => {
|
|
@@ -58,18 +62,24 @@ function localCallback() {
|
|
|
58
62
|
reject(new Error("Failed to allocate local callback port"));
|
|
59
63
|
return;
|
|
60
64
|
}
|
|
61
|
-
resolve({
|
|
65
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/zerno-mcp/callback`, result });
|
|
62
66
|
});
|
|
63
67
|
});
|
|
64
68
|
}
|
|
65
|
-
|
|
69
|
+
function successHtml(ok, error) {
|
|
70
|
+
const title = ok ? "ZERNO MCP connected" : "ZERNO MCP setup failed";
|
|
71
|
+
const body = ok
|
|
72
|
+
? "You can close this tab and return to the terminal."
|
|
73
|
+
: `Authorization failed: ${escapeHtml(error || "unknown error")}`;
|
|
74
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#f5f5f5;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.card{width:min(520px,calc(100vw - 32px));border:1px solid #333;border-radius:20px;background:#1b1b1b;padding:32px;box-shadow:0 20px 80px #0008}.mark{width:44px;height:44px;border-radius:14px;background:#9cff00;margin-bottom:20px}h1{font-size:28px;line-height:1.1;margin:0 0 12px}p{color:#bbb;font-size:15px;line-height:1.5;margin:0}</style></head><body><main class="card"><div class="mark"></div><h1>${title}</h1><p>${body}</p></main></body></html>`;
|
|
75
|
+
}
|
|
76
|
+
function escapeHtml(value) {
|
|
77
|
+
return value.replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch] || ch));
|
|
78
|
+
}
|
|
79
|
+
async function apiRequest(apiUrl, path, init = {}) {
|
|
66
80
|
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}${path}`, {
|
|
67
81
|
...init,
|
|
68
|
-
headers: {
|
|
69
|
-
"Content-Type": "application/json",
|
|
70
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
71
|
-
...(init.headers || {}),
|
|
72
|
-
},
|
|
82
|
+
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
|
73
83
|
});
|
|
74
84
|
if (!response.ok) {
|
|
75
85
|
const body = await response.text().catch(() => "");
|
|
@@ -77,74 +87,53 @@ async function apiRequest(apiUrl, path, token, init = {}) {
|
|
|
77
87
|
}
|
|
78
88
|
return (await response.json());
|
|
79
89
|
}
|
|
80
|
-
async function
|
|
81
|
-
const callback = await
|
|
82
|
-
const
|
|
83
|
-
redirectUri: callback.url,
|
|
84
|
-
deviceName: `${hostname()} zerno-mcp setup`,
|
|
85
|
-
platform: platform(),
|
|
86
|
-
deviceId: `${hostname()}-${platform()}`,
|
|
87
|
-
});
|
|
88
|
-
const authUrl = `${apiUrl.replace(/\/+$/, "")}/api/desktop/auth/browser/start?${query.toString()}`;
|
|
89
|
-
console.log("Opening ZERNO login in your browser...");
|
|
90
|
-
console.log(`If it does not open, paste this URL:\n${authUrl}\n`);
|
|
91
|
-
openBrowser(authUrl);
|
|
92
|
-
const browserToken = await callback.token;
|
|
93
|
-
const effectiveApiUrl = browserToken.apiBaseUrl || apiUrl;
|
|
94
|
-
const verified = await apiRequest(effectiveApiUrl, "/api/desktop/auth/verify-token", null, {
|
|
90
|
+
async function oauthConnect(apiUrl) {
|
|
91
|
+
const callback = await callbackServer();
|
|
92
|
+
const registered = await apiRequest(apiUrl, "/oauth/register", {
|
|
95
93
|
method: "POST",
|
|
96
94
|
body: JSON.stringify({
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
client_name: "ZERNO MCP CLI",
|
|
96
|
+
redirect_uris: [callback.redirectUri],
|
|
97
|
+
grant_types: ["authorization_code"],
|
|
98
|
+
response_types: ["code"],
|
|
99
|
+
token_endpoint_auth_method: "none",
|
|
100
|
+
scope: "mcp",
|
|
101
101
|
}),
|
|
102
102
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
throw new Error(`Project not found: ${requestedProjectId}`);
|
|
114
|
-
return match;
|
|
115
|
-
}
|
|
116
|
-
if (data.projects.length === 1) {
|
|
117
|
-
console.log(`Using project: ${data.projects[0].name}`);
|
|
118
|
-
return data.projects[0];
|
|
119
|
-
}
|
|
120
|
-
console.log("Choose ZERNO project:");
|
|
121
|
-
data.projects.forEach((project, index) => {
|
|
122
|
-
console.log(`${index + 1}. ${project.name}${project.slug ? ` (${project.slug})` : ""}`);
|
|
103
|
+
const verifier = newOpaque(48);
|
|
104
|
+
const state = newOpaque(24);
|
|
105
|
+
const params = new URLSearchParams({
|
|
106
|
+
response_type: "code",
|
|
107
|
+
client_id: registered.client_id,
|
|
108
|
+
redirect_uri: callback.redirectUri,
|
|
109
|
+
scope: "mcp",
|
|
110
|
+
state,
|
|
111
|
+
code_challenge: pkceChallenge(verifier),
|
|
112
|
+
code_challenge_method: "S256",
|
|
123
113
|
});
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
114
|
+
const authUrl = `${apiUrl.replace(/\/+$/, "")}/oauth/authorize?${params.toString()}`;
|
|
115
|
+
console.log("Opening ZERNO authorization in your browser...");
|
|
116
|
+
console.log(`If it does not open, paste this URL:\n${authUrl}\n`);
|
|
117
|
+
openBrowser(authUrl);
|
|
118
|
+
const result = await callback.result;
|
|
119
|
+
if (result.state !== state)
|
|
120
|
+
throw new Error("OAuth state mismatch");
|
|
121
|
+
const form = new URLSearchParams({
|
|
122
|
+
grant_type: "authorization_code",
|
|
123
|
+
code: result.code,
|
|
124
|
+
redirect_uri: callback.redirectUri,
|
|
125
|
+
client_id: registered.client_id,
|
|
126
|
+
code_verifier: verifier,
|
|
127
|
+
});
|
|
128
|
+
const token = await apiRequest(apiUrl, "/oauth/token", {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
131
|
+
body: form.toString(),
|
|
132
|
+
});
|
|
133
|
+
return { token: token.access_token, projectId: token.project_id };
|
|
141
134
|
}
|
|
142
135
|
function runtimeEnv(apiUrl, rawToken, projectId) {
|
|
143
|
-
return {
|
|
144
|
-
ZERNO_API_URL: apiUrl,
|
|
145
|
-
ZERNO_API_TOKEN: rawToken,
|
|
146
|
-
ZERNO_PROJECT_ID: projectId,
|
|
147
|
-
};
|
|
136
|
+
return { ZERNO_API_URL: apiUrl, ZERNO_API_TOKEN: rawToken, ZERNO_PROJECT_ID: projectId };
|
|
148
137
|
}
|
|
149
138
|
function writeJson(filePath, value) {
|
|
150
139
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -193,9 +182,7 @@ function writeCodex(env) {
|
|
|
193
182
|
].join("\n");
|
|
194
183
|
const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
195
184
|
const pattern = new RegExp(`${MANAGED_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${MANAGED_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "m");
|
|
196
|
-
const next = pattern.test(existing)
|
|
197
|
-
? existing.replace(pattern, managed)
|
|
198
|
-
: `${existing.trimEnd()}\n\n${managed}`;
|
|
185
|
+
const next = pattern.test(existing) ? existing.replace(pattern, managed) : `${existing.trimEnd()}\n\n${managed}`;
|
|
199
186
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
200
187
|
writeFileSync(filePath, next, { mode: 0o600 });
|
|
201
188
|
return filePath;
|
|
@@ -203,9 +190,7 @@ function writeCodex(env) {
|
|
|
203
190
|
function writeClaude(env) {
|
|
204
191
|
const filePath = claudeConfigPath();
|
|
205
192
|
const cfg = readJson(filePath);
|
|
206
|
-
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object"
|
|
207
|
-
? cfg.mcpServers
|
|
208
|
-
: {};
|
|
193
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
|
|
209
194
|
servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
|
|
210
195
|
cfg.mcpServers = servers;
|
|
211
196
|
writeJson(filePath, cfg);
|
|
@@ -215,42 +200,40 @@ function writeOpenCode(env) {
|
|
|
215
200
|
const filePath = join(process.cwd(), "opencode.jsonc");
|
|
216
201
|
const cfg = readJson(filePath);
|
|
217
202
|
const mcp = cfg.mcp && typeof cfg.mcp === "object" ? cfg.mcp : {};
|
|
218
|
-
mcp.zerno = {
|
|
219
|
-
type: "local",
|
|
220
|
-
command: ["npx", "-y", PACKAGE_SPEC],
|
|
221
|
-
enabled: true,
|
|
222
|
-
environment: env,
|
|
223
|
-
};
|
|
203
|
+
mcp.zerno = { type: "local", command: ["npx", "-y", PACKAGE_SPEC], enabled: true, environment: env };
|
|
224
204
|
cfg.mcp = mcp;
|
|
225
205
|
writeJson(filePath, cfg);
|
|
226
206
|
ensureGitignoreLine("opencode.jsonc");
|
|
227
207
|
return filePath;
|
|
228
208
|
}
|
|
209
|
+
function writeClaudeCode(env) {
|
|
210
|
+
// Project-scoped Claude Code config: `<cwd>/.mcp.json`. Same shape as
|
|
211
|
+
// Claude Desktop's `claude_desktop_config.json` (mcpServers map).
|
|
212
|
+
const filePath = join(process.cwd(), ".mcp.json");
|
|
213
|
+
const cfg = readJson(filePath);
|
|
214
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
|
|
215
|
+
servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
|
|
216
|
+
cfg.mcpServers = servers;
|
|
217
|
+
writeJson(filePath, cfg);
|
|
218
|
+
ensureGitignoreLine(".mcp.json");
|
|
219
|
+
return filePath;
|
|
220
|
+
}
|
|
229
221
|
export async function runSetup(args) {
|
|
230
222
|
if (args.includes("--help") || args.includes("-h")) {
|
|
231
223
|
console.log([
|
|
232
|
-
"Usage: npx -y zerno-mcp@0.1.
|
|
224
|
+
"Usage: npx -y zerno-mcp@0.1.2 setup [--api-url https://zerno.one]",
|
|
233
225
|
"",
|
|
234
|
-
"Opens
|
|
235
|
-
"Codex, Claude Desktop, and OpenCode MCP configs.",
|
|
226
|
+
"Opens the hosted ZERNO OAuth consent screen, lets the user choose a project,",
|
|
227
|
+
"and writes local Codex, Claude Desktop, and OpenCode MCP configs.",
|
|
236
228
|
].join("\n"));
|
|
237
229
|
return;
|
|
238
230
|
}
|
|
239
231
|
const apiUrl = argValue(args, "--api-url") || DEFAULT_API_URL;
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const env = runtimeEnv(auth.apiUrl, rawToken, project.id);
|
|
246
|
-
const written = [
|
|
247
|
-
["Codex", writeCodex(env)],
|
|
248
|
-
["Claude Desktop", writeClaude(env)],
|
|
249
|
-
["OpenCode", writeOpenCode(env)],
|
|
250
|
-
];
|
|
251
|
-
console.log(`\nZERNO MCP is configured for project: ${project.name}`);
|
|
252
|
-
for (const [client, filePath] of written) {
|
|
232
|
+
const auth = await oauthConnect(apiUrl);
|
|
233
|
+
const env = runtimeEnv(apiUrl, auth.token, auth.projectId);
|
|
234
|
+
const written = [["Codex", writeCodex(env)], ["Claude Desktop", writeClaude(env)], ["OpenCode", writeOpenCode(env)]];
|
|
235
|
+
console.log("\nZERNO MCP is configured.");
|
|
236
|
+
for (const [client, filePath] of written)
|
|
253
237
|
console.log(`- ${client}: ${filePath}`);
|
|
254
|
-
}
|
|
255
238
|
console.log("\nRestart your agent so it reloads MCP servers.");
|
|
256
239
|
}
|