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 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.1 setup
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.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.1" }, { capabilities: { tools: {} } });
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, hostname, platform } from "node:os";
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.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 localCallback() {
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("/desktop-auth/callback")) {
43
+ if (!requestUrl.pathname.startsWith("/zerno-mcp/callback")) {
30
44
  res.writeHead(404).end("Not found");
31
45
  return;
32
46
  }
33
- const desktopToken = requestUrl.searchParams.get("desktopToken") || "";
34
- const apiBaseUrl = requestUrl.searchParams.get("apiBaseUrl") || undefined;
35
- res
36
- .writeHead(desktopToken ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" })
37
- .end(desktopToken
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 (desktopToken) {
42
- tokenResolve({ desktopToken, apiBaseUrl });
43
- }
44
- else {
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({ url: `http://127.0.0.1:${address.port}/desktop-auth/callback`, token });
65
+ resolve({ redirectUri: `http://127.0.0.1:${address.port}/zerno-mcp/callback`, result });
62
66
  });
63
67
  });
64
68
  }
65
- async function apiRequest(apiUrl, path, token, init = {}) {
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 browserLogin(apiUrl) {
81
- const callback = await localCallback();
82
- const query = new URLSearchParams({
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
- token: browserToken.desktopToken,
98
- deviceName: `${hostname()} zerno-mcp setup`,
99
- platform: platform(),
100
- deviceId: `${hostname()}-${platform()}`,
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
- return { apiUrl: effectiveApiUrl, accessToken: verified.accessToken, email: verified.user.email };
104
- }
105
- async function selectProject(apiUrl, accessToken, requestedProjectId) {
106
- const data = await apiRequest(apiUrl, "/api/desktop/projects", accessToken);
107
- if (data.projects.length === 0) {
108
- throw new Error("No ZERNO projects found for this account.");
109
- }
110
- if (requestedProjectId) {
111
- const match = data.projects.find((project) => project.id === requestedProjectId || project.slug === requestedProjectId);
112
- if (!match)
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 rl = createInterface({ input, output });
125
- try {
126
- for (;;) {
127
- const answer = await rl.question("Project number: ");
128
- const idx = Number(answer.trim());
129
- if (Number.isInteger(idx) && idx >= 1 && idx <= data.projects.length) {
130
- return data.projects[idx - 1];
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
- finally {
135
- rl.close();
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
- return {
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.1 setup [--api-url https://zerno.one] [--project <id-or-slug>]",
243
+ "Usage: npx -y zerno-mcp@0.1.4 setup [--api-url https://zerno.one]",
233
244
  "",
234
- "Opens browser login, creates a project-scoped MCP token, and writes local",
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 projectArg = argValue(args, "--project");
241
- const auth = await browserLogin(apiUrl);
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(`\nZERNO MCP is configured for project: ${project.name}`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zerno-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "MCP server connecting OpenCode to ZERNO cloud product brain",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",