zerno-mcp 0.1.0 → 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 +12 -1
- package/dist/index.js +7 -2
- package/dist/setup.js +239 -0
- package/package.json +1 -1
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.2 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.
|
|
19
|
+
npx -y zerno-mcp@0.1.2
|
|
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
|
-
|
|
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.2" }, { 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,239 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, platform } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
const DEFAULT_API_URL = "https://zerno.one";
|
|
8
|
+
const PACKAGE_SPEC = "zerno-mcp@0.1.2";
|
|
9
|
+
const MANAGED_START = "# ZERNO MCP:START";
|
|
10
|
+
const MANAGED_END = "# ZERNO MCP:END";
|
|
11
|
+
function argValue(args, name) {
|
|
12
|
+
const prefix = `${name}=`;
|
|
13
|
+
const direct = args.find((arg) => arg.startsWith(prefix));
|
|
14
|
+
if (direct)
|
|
15
|
+
return direct.slice(prefix.length);
|
|
16
|
+
const idx = args.indexOf(name);
|
|
17
|
+
return idx >= 0 ? args[idx + 1] : undefined;
|
|
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
|
+
}
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
30
|
+
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
|
|
31
|
+
spawnSync(command, args, { stdio: "ignore" });
|
|
32
|
+
}
|
|
33
|
+
function callbackServer() {
|
|
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
|
+
});
|
|
41
|
+
const server = createServer((req, res) => {
|
|
42
|
+
const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
43
|
+
if (!requestUrl.pathname.startsWith("/zerno-mcp/callback")) {
|
|
44
|
+
res.writeHead(404).end("Not found");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
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));
|
|
52
|
+
server.close();
|
|
53
|
+
if (ok)
|
|
54
|
+
done({ code, state });
|
|
55
|
+
else
|
|
56
|
+
fail(new Error(error || "OAuth callback did not include code"));
|
|
57
|
+
});
|
|
58
|
+
server.on("error", reject);
|
|
59
|
+
server.listen(0, "127.0.0.1", () => {
|
|
60
|
+
const address = server.address();
|
|
61
|
+
if (!address || typeof address === "string") {
|
|
62
|
+
reject(new Error("Failed to allocate local callback port"));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/zerno-mcp/callback`, result });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
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 = {}) {
|
|
80
|
+
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}${path}`, {
|
|
81
|
+
...init,
|
|
82
|
+
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const body = await response.text().catch(() => "");
|
|
86
|
+
throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
return (await response.json());
|
|
89
|
+
}
|
|
90
|
+
async function oauthConnect(apiUrl) {
|
|
91
|
+
const callback = await callbackServer();
|
|
92
|
+
const registered = await apiRequest(apiUrl, "/oauth/register", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: JSON.stringify({
|
|
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
|
+
}),
|
|
102
|
+
});
|
|
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",
|
|
113
|
+
});
|
|
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 };
|
|
134
|
+
}
|
|
135
|
+
function runtimeEnv(apiUrl, rawToken, projectId) {
|
|
136
|
+
return { ZERNO_API_URL: apiUrl, ZERNO_API_TOKEN: rawToken, ZERNO_PROJECT_ID: projectId };
|
|
137
|
+
}
|
|
138
|
+
function writeJson(filePath, value) {
|
|
139
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
140
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
141
|
+
}
|
|
142
|
+
function ensureGitignoreLine(line) {
|
|
143
|
+
const filePath = join(process.cwd(), ".gitignore");
|
|
144
|
+
const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
145
|
+
if (existing.split(/\r?\n/).map((item) => item.trim()).includes(line))
|
|
146
|
+
return;
|
|
147
|
+
writeFileSync(filePath, `${existing.trimEnd()}\n${line}\n`, "utf8");
|
|
148
|
+
}
|
|
149
|
+
function stripJsonComments(input) {
|
|
150
|
+
return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
151
|
+
}
|
|
152
|
+
function readJson(filePath) {
|
|
153
|
+
if (!existsSync(filePath))
|
|
154
|
+
return {};
|
|
155
|
+
return JSON.parse(stripJsonComments(readFileSync(filePath, "utf8")));
|
|
156
|
+
}
|
|
157
|
+
function quoteToml(value) {
|
|
158
|
+
return JSON.stringify(value);
|
|
159
|
+
}
|
|
160
|
+
function codexConfigPath() {
|
|
161
|
+
return join(homedir(), ".codex", "config.toml");
|
|
162
|
+
}
|
|
163
|
+
function claudeConfigPath() {
|
|
164
|
+
if (platform() === "darwin")
|
|
165
|
+
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
166
|
+
if (platform() === "win32")
|
|
167
|
+
return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
168
|
+
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
169
|
+
}
|
|
170
|
+
function writeCodex(env) {
|
|
171
|
+
const filePath = codexConfigPath();
|
|
172
|
+
const managed = [
|
|
173
|
+
MANAGED_START,
|
|
174
|
+
"[mcp_servers.zerno]",
|
|
175
|
+
'command = "npx"',
|
|
176
|
+
`args = ["-y", "${PACKAGE_SPEC}"]`,
|
|
177
|
+
"",
|
|
178
|
+
"[mcp_servers.zerno.env]",
|
|
179
|
+
...Object.entries(env).map(([key, value]) => `${key} = ${quoteToml(value)}`),
|
|
180
|
+
MANAGED_END,
|
|
181
|
+
"",
|
|
182
|
+
].join("\n");
|
|
183
|
+
const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
184
|
+
const pattern = new RegExp(`${MANAGED_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${MANAGED_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "m");
|
|
185
|
+
const next = pattern.test(existing) ? existing.replace(pattern, managed) : `${existing.trimEnd()}\n\n${managed}`;
|
|
186
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
187
|
+
writeFileSync(filePath, next, { mode: 0o600 });
|
|
188
|
+
return filePath;
|
|
189
|
+
}
|
|
190
|
+
function writeClaude(env) {
|
|
191
|
+
const filePath = claudeConfigPath();
|
|
192
|
+
const cfg = readJson(filePath);
|
|
193
|
+
const servers = cfg.mcpServers && typeof cfg.mcpServers === "object" ? cfg.mcpServers : {};
|
|
194
|
+
servers.zerno = { command: "npx", args: ["-y", PACKAGE_SPEC], env };
|
|
195
|
+
cfg.mcpServers = servers;
|
|
196
|
+
writeJson(filePath, cfg);
|
|
197
|
+
return filePath;
|
|
198
|
+
}
|
|
199
|
+
function writeOpenCode(env) {
|
|
200
|
+
const filePath = join(process.cwd(), "opencode.jsonc");
|
|
201
|
+
const cfg = readJson(filePath);
|
|
202
|
+
const mcp = cfg.mcp && typeof cfg.mcp === "object" ? cfg.mcp : {};
|
|
203
|
+
mcp.zerno = { type: "local", command: ["npx", "-y", PACKAGE_SPEC], enabled: true, environment: env };
|
|
204
|
+
cfg.mcp = mcp;
|
|
205
|
+
writeJson(filePath, cfg);
|
|
206
|
+
ensureGitignoreLine("opencode.jsonc");
|
|
207
|
+
return filePath;
|
|
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
|
+
}
|
|
221
|
+
export async function runSetup(args) {
|
|
222
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
223
|
+
console.log([
|
|
224
|
+
"Usage: npx -y zerno-mcp@0.1.2 setup [--api-url https://zerno.one]",
|
|
225
|
+
"",
|
|
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.",
|
|
228
|
+
].join("\n"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const apiUrl = argValue(args, "--api-url") || DEFAULT_API_URL;
|
|
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)
|
|
237
|
+
console.log(`- ${client}: ${filePath}`);
|
|
238
|
+
console.log("\nRestart your agent so it reloads MCP servers.");
|
|
239
|
+
}
|