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 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.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, 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.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 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,53 @@ 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
- }
133
- }
134
- finally {
135
- rl.close();
136
- }
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;
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.1 setup [--api-url https://zerno.one] [--project <id-or-slug>]",
224
+ "Usage: npx -y zerno-mcp@0.1.2 setup [--api-url https://zerno.one]",
233
225
  "",
234
- "Opens browser login, creates a project-scoped MCP token, and writes local",
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 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);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zerno-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server connecting OpenCode to ZERNO cloud product brain",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",