zerno-mcp 0.1.0 → 0.1.1

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
@@ -4,8 +4,19 @@ Local stdio MCP server for ZERNO code-agent integrations.
4
4
 
5
5
  ## Usage
6
6
 
7
+ Recommended setup:
8
+
9
+ ```bash
10
+ npx -y zerno-mcp@0.1.1 setup
11
+ ```
12
+
13
+ The setup command opens ZERNO in the browser, lets the user choose a project,
14
+ creates the project-scoped MCP token, and writes local agent configs.
15
+
16
+ Agent runtime:
17
+
7
18
  ```bash
8
- npx -y zerno-mcp@0.1.0
19
+ npx -y zerno-mcp@0.1.1
9
20
  ```
10
21
 
11
22
  Required environment:
package/dist/index.js CHANGED
@@ -14,10 +14,15 @@
14
14
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
16
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
17
- import * as client from "./zerno-client.js";
18
17
  import { renderTaskCardHTML, renderTaskListHTML, renderBacklogHealthHTML, renderTaskCardMarkdown, renderTaskListMarkdown, renderBacklogHealthMarkdown, } from "./ui/widgets.js";
19
18
  import { renderStrategyBriefHTML, renderStrategyBriefMarkdown, renderAlignmentHTML, renderAlignmentMarkdown, renderProductHealthHTML, renderProductHealthMarkdown, renderWeeklyReviewHTML, renderWeeklyReviewMarkdown, } from "./ui/brain.js";
20
- const server = new Server({ name: "zerno-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
19
+ if (process.argv[2] === "setup") {
20
+ const { runSetup } = await import("./setup.js");
21
+ await runSetup(process.argv.slice(3));
22
+ process.exit(0);
23
+ }
24
+ const client = await import("./zerno-client.js");
25
+ const server = new Server({ name: "zerno-mcp", version: "0.1.1" }, { capabilities: { tools: {} } });
21
26
  function asRecord(value) {
22
27
  if (value && typeof value === "object") {
23
28
  return value;
package/dist/setup.js ADDED
@@ -0,0 +1,256 @@
1
+ import { createServer } from "node:http";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir, hostname, platform } from "node:os";
5
+ 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
+ const DEFAULT_API_URL = "https://zerno.one";
9
+ const PACKAGE_SPEC = "zerno-mcp@0.1.1";
10
+ const MANAGED_START = "# ZERNO MCP:START";
11
+ const MANAGED_END = "# ZERNO MCP:END";
12
+ function argValue(args, name) {
13
+ const prefix = `${name}=`;
14
+ const direct = args.find((arg) => arg.startsWith(prefix));
15
+ if (direct)
16
+ return direct.slice(prefix.length);
17
+ const idx = args.indexOf(name);
18
+ return idx >= 0 ? args[idx + 1] : undefined;
19
+ }
20
+ function openBrowser(url) {
21
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
22
+ const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
23
+ spawnSync(command, args, { stdio: "ignore" });
24
+ }
25
+ function localCallback() {
26
+ return new Promise((resolve, reject) => {
27
+ const server = createServer((req, res) => {
28
+ const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
29
+ if (!requestUrl.pathname.startsWith("/desktop-auth/callback")) {
30
+ res.writeHead(404).end("Not found");
31
+ return;
32
+ }
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>");
40
+ 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
+ });
54
+ server.on("error", reject);
55
+ server.listen(0, "127.0.0.1", () => {
56
+ const address = server.address();
57
+ if (!address || typeof address === "string") {
58
+ reject(new Error("Failed to allocate local callback port"));
59
+ return;
60
+ }
61
+ resolve({ url: `http://127.0.0.1:${address.port}/desktop-auth/callback`, token });
62
+ });
63
+ });
64
+ }
65
+ async function apiRequest(apiUrl, path, token, init = {}) {
66
+ const response = await fetch(`${apiUrl.replace(/\/+$/, "")}${path}`, {
67
+ ...init,
68
+ headers: {
69
+ "Content-Type": "application/json",
70
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
71
+ ...(init.headers || {}),
72
+ },
73
+ });
74
+ if (!response.ok) {
75
+ const body = await response.text().catch(() => "");
76
+ throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
77
+ }
78
+ return (await response.json());
79
+ }
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, {
95
+ method: "POST",
96
+ body: JSON.stringify({
97
+ token: browserToken.desktopToken,
98
+ deviceName: `${hostname()} zerno-mcp setup`,
99
+ platform: platform(),
100
+ deviceId: `${hostname()}-${platform()}`,
101
+ }),
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})` : ""}`);
123
+ });
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;
141
+ }
142
+ function runtimeEnv(apiUrl, rawToken, projectId) {
143
+ return {
144
+ ZERNO_API_URL: apiUrl,
145
+ ZERNO_API_TOKEN: rawToken,
146
+ ZERNO_PROJECT_ID: projectId,
147
+ };
148
+ }
149
+ function writeJson(filePath, value) {
150
+ mkdirSync(dirname(filePath), { recursive: true });
151
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
152
+ }
153
+ function ensureGitignoreLine(line) {
154
+ const filePath = join(process.cwd(), ".gitignore");
155
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
156
+ if (existing.split(/\r?\n/).map((item) => item.trim()).includes(line))
157
+ return;
158
+ writeFileSync(filePath, `${existing.trimEnd()}\n${line}\n`, "utf8");
159
+ }
160
+ function stripJsonComments(input) {
161
+ return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
162
+ }
163
+ function readJson(filePath) {
164
+ if (!existsSync(filePath))
165
+ return {};
166
+ return JSON.parse(stripJsonComments(readFileSync(filePath, "utf8")));
167
+ }
168
+ function quoteToml(value) {
169
+ return JSON.stringify(value);
170
+ }
171
+ function codexConfigPath() {
172
+ return join(homedir(), ".codex", "config.toml");
173
+ }
174
+ function claudeConfigPath() {
175
+ if (platform() === "darwin")
176
+ return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
177
+ if (platform() === "win32")
178
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
179
+ return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
180
+ }
181
+ function writeCodex(env) {
182
+ const filePath = codexConfigPath();
183
+ const managed = [
184
+ MANAGED_START,
185
+ "[mcp_servers.zerno]",
186
+ 'command = "npx"',
187
+ `args = ["-y", "${PACKAGE_SPEC}"]`,
188
+ "",
189
+ "[mcp_servers.zerno.env]",
190
+ ...Object.entries(env).map(([key, value]) => `${key} = ${quoteToml(value)}`),
191
+ MANAGED_END,
192
+ "",
193
+ ].join("\n");
194
+ const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
195
+ 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}`;
199
+ mkdirSync(dirname(filePath), { recursive: true });
200
+ writeFileSync(filePath, next, { mode: 0o600 });
201
+ return filePath;
202
+ }
203
+ function writeClaude(env) {
204
+ const filePath = claudeConfigPath();
205
+ const cfg = readJson(filePath);
206
+ const servers = cfg.mcpServers && typeof cfg.mcpServers === "object"
207
+ ? cfg.mcpServers
208
+ : {};
209
+ servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
210
+ cfg.mcpServers = servers;
211
+ writeJson(filePath, cfg);
212
+ return filePath;
213
+ }
214
+ function writeOpenCode(env) {
215
+ const filePath = join(process.cwd(), "opencode.jsonc");
216
+ const cfg = readJson(filePath);
217
+ 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
+ };
224
+ cfg.mcp = mcp;
225
+ writeJson(filePath, cfg);
226
+ ensureGitignoreLine("opencode.jsonc");
227
+ return filePath;
228
+ }
229
+ export async function runSetup(args) {
230
+ if (args.includes("--help") || args.includes("-h")) {
231
+ console.log([
232
+ "Usage: npx -y zerno-mcp@0.1.1 setup [--api-url https://zerno.one] [--project <id-or-slug>]",
233
+ "",
234
+ "Opens browser login, creates a project-scoped MCP token, and writes local",
235
+ "Codex, Claude Desktop, and OpenCode MCP configs.",
236
+ ].join("\n"));
237
+ return;
238
+ }
239
+ 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) {
253
+ console.log(`- ${client}: ${filePath}`);
254
+ }
255
+ console.log("\nRestart your agent so it reloads MCP servers.");
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zerno-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server connecting OpenCode to ZERNO cloud product brain",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",