zerno-mcp 0.1.1 → 0.1.4
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 +118 -111
- 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.4" }, { 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.4";
|
|
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,69 @@ 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
|
-
|
|
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
|
+
if (!token.access_token) {
|
|
134
|
+
throw new Error("OAuth token response missing access_token");
|
|
133
135
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
if (!token.project_id) {
|
|
137
|
+
throw new Error("OAuth token response missing project_id — backend likely on an old build. Re-run setup against the updated server.");
|
|
136
138
|
}
|
|
137
|
-
}
|
|
138
|
-
async function createMcpToken(apiUrl, accessToken, projectId) {
|
|
139
|
-
const data = await apiRequest(apiUrl, `/api/desktop/projects/${projectId}/mcp-token`, accessToken, { method: "POST" });
|
|
140
|
-
return data.raw_token;
|
|
139
|
+
return { token: token.access_token, projectId: token.project_id };
|
|
141
140
|
}
|
|
142
141
|
function runtimeEnv(apiUrl, rawToken, projectId) {
|
|
143
|
-
|
|
142
|
+
const env = {
|
|
144
143
|
ZERNO_API_URL: apiUrl,
|
|
145
144
|
ZERNO_API_TOKEN: rawToken,
|
|
146
145
|
ZERNO_PROJECT_ID: projectId,
|
|
147
146
|
};
|
|
147
|
+
for (const [key, value] of Object.entries(env)) {
|
|
148
|
+
if (!value || typeof value !== "string") {
|
|
149
|
+
throw new Error(`Refusing to write config: env value for ${key} is empty`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return env;
|
|
148
153
|
}
|
|
149
154
|
function writeJson(filePath, value) {
|
|
150
155
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -166,6 +171,9 @@ function readJson(filePath) {
|
|
|
166
171
|
return JSON.parse(stripJsonComments(readFileSync(filePath, "utf8")));
|
|
167
172
|
}
|
|
168
173
|
function quoteToml(value) {
|
|
174
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
175
|
+
throw new Error(`Cannot serialise empty value to TOML string`);
|
|
176
|
+
}
|
|
169
177
|
return JSON.stringify(value);
|
|
170
178
|
}
|
|
171
179
|
function codexConfigPath() {
|
|
@@ -193,9 +201,7 @@ function writeCodex(env) {
|
|
|
193
201
|
].join("\n");
|
|
194
202
|
const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
195
203
|
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}`;
|
|
204
|
+
const next = pattern.test(existing) ? existing.replace(pattern, managed) : `${existing.trimEnd()}\n\n${managed}`;
|
|
199
205
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
200
206
|
writeFileSync(filePath, next, { mode: 0o600 });
|
|
201
207
|
return filePath;
|
|
@@ -203,9 +209,7 @@ function writeCodex(env) {
|
|
|
203
209
|
function writeClaude(env) {
|
|
204
210
|
const filePath = claudeConfigPath();
|
|
205
211
|
const cfg = readJson(filePath);
|
|
206
|
-
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object"
|
|
207
|
-
? cfg.mcpServers
|
|
208
|
-
: {};
|
|
212
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
|
|
209
213
|
servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
|
|
210
214
|
cfg.mcpServers = servers;
|
|
211
215
|
writeJson(filePath, cfg);
|
|
@@ -215,42 +219,45 @@ function writeOpenCode(env) {
|
|
|
215
219
|
const filePath = join(process.cwd(), "opencode.jsonc");
|
|
216
220
|
const cfg = readJson(filePath);
|
|
217
221
|
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
|
-
};
|
|
222
|
+
mcp.zerno = { type: "local", command: ["npx", "-y", PACKAGE_SPEC], enabled: true, environment: env };
|
|
224
223
|
cfg.mcp = mcp;
|
|
225
224
|
writeJson(filePath, cfg);
|
|
226
225
|
ensureGitignoreLine("opencode.jsonc");
|
|
227
226
|
return filePath;
|
|
228
227
|
}
|
|
228
|
+
function writeClaudeCode(env) {
|
|
229
|
+
// Project-scoped Claude Code config: `<cwd>/.mcp.json`. Same shape as
|
|
230
|
+
// Claude Desktop's `claude_desktop_config.json` (mcpServers map).
|
|
231
|
+
const filePath = join(process.cwd(), ".mcp.json");
|
|
232
|
+
const cfg = readJson(filePath);
|
|
233
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
|
|
234
|
+
servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
|
|
235
|
+
cfg.mcpServers = servers;
|
|
236
|
+
writeJson(filePath, cfg);
|
|
237
|
+
ensureGitignoreLine(".mcp.json");
|
|
238
|
+
return filePath;
|
|
239
|
+
}
|
|
229
240
|
export async function runSetup(args) {
|
|
230
241
|
if (args.includes("--help") || args.includes("-h")) {
|
|
231
242
|
console.log([
|
|
232
|
-
"Usage: npx -y zerno-mcp@0.1.
|
|
243
|
+
"Usage: npx -y zerno-mcp@0.1.4 setup [--api-url https://zerno.one]",
|
|
233
244
|
"",
|
|
234
|
-
"Opens
|
|
235
|
-
"Codex, Claude Desktop, and OpenCode MCP configs.",
|
|
245
|
+
"Opens the hosted ZERNO OAuth consent screen, lets the user choose a project,",
|
|
246
|
+
"and writes local Codex, Claude Desktop, Claude Code, and OpenCode MCP configs.",
|
|
236
247
|
].join("\n"));
|
|
237
248
|
return;
|
|
238
249
|
}
|
|
239
250
|
const apiUrl = argValue(args, "--api-url") || DEFAULT_API_URL;
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
console.log(`Logged in as ${auth.email}`);
|
|
243
|
-
const project = await selectProject(auth.apiUrl, auth.accessToken, projectArg);
|
|
244
|
-
const rawToken = await createMcpToken(auth.apiUrl, auth.accessToken, project.id);
|
|
245
|
-
const env = runtimeEnv(auth.apiUrl, rawToken, project.id);
|
|
251
|
+
const auth = await oauthConnect(apiUrl);
|
|
252
|
+
const env = runtimeEnv(apiUrl, auth.token, auth.projectId);
|
|
246
253
|
const written = [
|
|
247
254
|
["Codex", writeCodex(env)],
|
|
248
255
|
["Claude Desktop", writeClaude(env)],
|
|
256
|
+
["Claude Code", writeClaudeCode(env)],
|
|
249
257
|
["OpenCode", writeOpenCode(env)],
|
|
250
258
|
];
|
|
251
|
-
console.log(
|
|
252
|
-
for (const [client, filePath] of written)
|
|
259
|
+
console.log("\nZERNO MCP is configured.");
|
|
260
|
+
for (const [client, filePath] of written)
|
|
253
261
|
console.log(`- ${client}: ${filePath}`);
|
|
254
|
-
}
|
|
255
262
|
console.log("\nRestart your agent so it reloads MCP servers.");
|
|
256
263
|
}
|