aztrx-cli 0.4.4 → 0.5.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 +325 -28
- package/dist/cli/help.d.ts +22 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +278 -81
- package/dist/core/auth.d.ts +33 -0
- package/dist/core/badge.d.ts +9 -0
- package/dist/core/browser.d.ts +3 -0
- package/dist/core/classifier.d.ts +41 -0
- package/dist/core/cloud/index.d.ts +62 -0
- package/dist/core/cloud/index.js +24 -4
- package/dist/core/devServer.d.ts +90 -0
- package/dist/core/devServer.js +253 -0
- package/dist/core/diagnose.d.ts +18 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/domWalker.d.ts +24 -0
- package/dist/core/domWalker.js +1 -1
- package/dist/core/eventBus.d.ts +59 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/fixPr.d.ts +16 -0
- package/dist/core/fixPr.js +11 -1
- package/dist/core/fuzzer.d.ts +24 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.d.ts +28 -0
- package/dist/core/heal/boot.d.ts +74 -0
- package/dist/core/heal/boot.js +126 -23
- package/dist/core/heal/childEnv.d.ts +15 -0
- package/dist/core/heal/gates.d.ts +11 -0
- package/dist/core/heal/index.d.ts +19 -0
- package/dist/core/heal/index.js +53 -23
- package/dist/core/heal/llm.d.ts +43 -0
- package/dist/core/heal/redact.d.ts +22 -0
- package/dist/core/heal/sandbox.d.ts +54 -0
- package/dist/core/heal/sandbox.js +20 -3
- package/dist/core/heal/types.d.ts +102 -0
- package/dist/core/heal/verify.d.ts +37 -0
- package/dist/core/heal/verify.js +28 -14
- package/dist/core/httpFuzzer.d.ts +33 -0
- package/dist/core/init.d.ts +21 -0
- package/dist/core/init.js +5 -6
- package/dist/core/interceptor.d.ts +21 -0
- package/dist/core/llm.d.ts +36 -0
- package/dist/core/llm.js +0 -8
- package/dist/core/minimizer.d.ts +13 -0
- package/dist/core/modernize.d.ts +28 -0
- package/dist/core/modernize.js +2 -2
- package/dist/core/networkGuard.d.ts +13 -0
- package/dist/core/orchestrator.d.ts +86 -0
- package/dist/core/orchestrator.js +24 -9
- package/dist/core/patrol/loop.d.ts +46 -0
- package/dist/core/patrol/pr.d.ts +42 -0
- package/dist/core/patrol/pr.js +9 -6
- package/dist/core/patrol/record.d.ts +51 -0
- package/dist/core/patrol/state.d.ts +39 -0
- package/dist/core/pr.d.ts +3 -0
- package/dist/core/pr.js +15 -11
- package/dist/core/prompt.d.ts +14 -0
- package/dist/core/recorder.d.ts +23 -0
- package/dist/core/recorder.js +1 -1
- package/dist/core/renderMarkdown.d.ts +7 -0
- package/dist/core/replay.d.ts +33 -0
- package/dist/core/replay.js +9 -2
- package/dist/core/report.d.ts +8 -0
- package/dist/core/resolver.d.ts +44 -0
- package/dist/core/resolver.js +51 -13
- package/dist/core/rng.d.ts +2 -0
- package/dist/core/specCompiler.d.ts +17 -0
- package/dist/core/studio.d.ts +6 -0
- package/dist/core/studio.js +0 -3
- package/dist/core/summarize.d.ts +26 -0
- package/dist/core/swarm.d.ts +97 -0
- package/dist/core/telemetry/index.d.ts +33 -0
- package/dist/core/telemetry/index.js +22 -4
- package/dist/core/telemetry/sanitize.d.ts +19 -0
- package/dist/core/telemetry/types.d.ts +26 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/core/ui.d.ts +23 -0
- package/dist/core/ui.js +0 -5
- package/dist/core/validator.d.ts +14 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/hooks/index.d.ts +106 -0
- package/dist/hooks/index.js +349 -0
- package/dist/mcp/index.d.ts +58 -0
- package/dist/mcp/index.js +295 -0
- package/dist/mcp/install.d.ts +52 -0
- package/dist/mcp/install.js +204 -0
- package/dist/mcp/protocol.d.ts +106 -0
- package/dist/mcp/protocol.js +156 -0
- package/dist/mcp/tools.d.ts +107 -0
- package/dist/mcp/tools.js +621 -0
- package/dist/next/index.d.ts +70 -0
- package/dist/next/index.js +103 -0
- package/dist/plugins/scan.d.ts +85 -0
- package/dist/plugins/scan.js +188 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/ui/app.js +14 -1
- package/dist/vite/index.d.ts +60 -0
- package/dist/vite/index.js +56 -0
- package/package.json +34 -4
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aztrx-cli mcp` — the server an editor talks to.
|
|
3
|
+
*
|
|
4
|
+
* stdio transport, newline-delimited JSON-RPC, one message per line. That is the
|
|
5
|
+
* whole interface; everything else is in `protocol.ts` (framing and the two
|
|
6
|
+
* protocol eras), `tools.ts` (the three tools) and `install.ts` (writing the
|
|
7
|
+
* editor config).
|
|
8
|
+
*
|
|
9
|
+
* Three things here are load-bearing and easy to get wrong:
|
|
10
|
+
*
|
|
11
|
+
* - **stdout belongs to the protocol.** One stray byte and the client's parser
|
|
12
|
+
* dies on a line it cannot read. The orchestrator is silent under `ui: true`,
|
|
13
|
+
* and `redirectStdoutToStderr` catches anything else that prints.
|
|
14
|
+
* - **Boot fast, import late.** Editors time out slow servers, so nothing heavy
|
|
15
|
+
* is imported at module load. Playwright arrives on the first scan, not on
|
|
16
|
+
* `initialize`.
|
|
17
|
+
* - **Exit when stdin ends.** The stdio shutdown sequence is: the client closes
|
|
18
|
+
* our stdin, waits, then escalates to SIGTERM and SIGKILL. A server that
|
|
19
|
+
* ignores the first step leaves an orphan holding a dev server's port and a
|
|
20
|
+
* Chromium handle after every editor session.
|
|
21
|
+
*/
|
|
22
|
+
import { format } from "util";
|
|
23
|
+
import { VERSION } from "../core/version.js";
|
|
24
|
+
import { checkVersion, decode, encode, error, INTERNAL_ERROR, INVALID_PARAMS, isNotification, METHOD_NOT_FOUND, META_SERVER_INFO, negotiate, ProtocolError, result, serverInfo, SUPPORTED_VERSIONS, supports, } from "./protocol.js";
|
|
25
|
+
import { McpRuntime, TOOLS } from "./tools.js";
|
|
26
|
+
/** What a model sees when the tools are first offered to it. Short on purpose:
|
|
27
|
+
* it is prepended to every conversation that has this server attached. */
|
|
28
|
+
const INSTRUCTIONS = "aztrx drives the app in a real browser and reports the runtime crashes it produced. " +
|
|
29
|
+
"Call aztrx_scan before claiming a change works — treat its clean result as the evidence " +
|
|
30
|
+
"that the app still runs. A scan that could not run says so; it never reports 'no findings' " +
|
|
31
|
+
"for an app it never reached. Findings are proven, not guessed: aztrx_repro shows the exact " +
|
|
32
|
+
"steps that reproduce one, and aztrx_fix patches it and re-verifies by replaying them.";
|
|
33
|
+
/** Nothing here varies per caller or per connection, so both list results are
|
|
34
|
+
* publicly cacheable. Long enough to be worth caching, short enough that a
|
|
35
|
+
* version upgrade is picked up without a restart. */
|
|
36
|
+
const TOOLS_TTL_MS = 300_000;
|
|
37
|
+
const DISCOVER_TTL_MS = 3_600_000;
|
|
38
|
+
/** Reroute the console's stdout-bound writers to stderr, for good.
|
|
39
|
+
*
|
|
40
|
+
* `ui: true` already silences the orchestrator, so this is the net under the net:
|
|
41
|
+
* a dependency's warning, or a `console.log` in some path nobody thought about,
|
|
42
|
+
* would otherwise land in the middle of the protocol stream. Nothing is lost by
|
|
43
|
+
* moving it — the spec sanctions stderr for *all* logging on stdio transports.
|
|
44
|
+
* `console.error` and `console.warn` already write to stderr and are left alone.
|
|
45
|
+
*
|
|
46
|
+
* Returns the undo, which the tests use to prove the guard can fail. */
|
|
47
|
+
export function redirectStdoutToStderr(target) {
|
|
48
|
+
const previous = {
|
|
49
|
+
log: console.log,
|
|
50
|
+
info: console.info,
|
|
51
|
+
debug: console.debug,
|
|
52
|
+
dir: console.dir,
|
|
53
|
+
};
|
|
54
|
+
const toStderr = (...args) => {
|
|
55
|
+
target.write(format(...args) + "\n");
|
|
56
|
+
};
|
|
57
|
+
console.log = toStderr;
|
|
58
|
+
console.info = toStderr;
|
|
59
|
+
console.debug = toStderr;
|
|
60
|
+
console.dir = ((item) => toStderr(item));
|
|
61
|
+
return () => {
|
|
62
|
+
Object.assign(console, previous);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export async function startServer(opts = {}) {
|
|
66
|
+
const real = opts.io === undefined;
|
|
67
|
+
const io = opts.io ?? {
|
|
68
|
+
input: process.stdin,
|
|
69
|
+
output: process.stdout,
|
|
70
|
+
error: process.stderr,
|
|
71
|
+
};
|
|
72
|
+
const runtime = opts.runtime ?? new McpRuntime({ defaultRepoRoot: opts.repoRoot });
|
|
73
|
+
const restoreConsole = redirectStdoutToStderr(io.error);
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
// Writing
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
/** One message, one line, written whole.
|
|
78
|
+
*
|
|
79
|
+
* No interleaving guard is needed: `process.stdout` is a Writable, and a
|
|
80
|
+
* Writable runs its queued chunks in the order they were written, so two
|
|
81
|
+
* responses can never splice into each other. */
|
|
82
|
+
function send(message) {
|
|
83
|
+
io.output.write(encode(message));
|
|
84
|
+
}
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
// Dispatch
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
let stopping;
|
|
89
|
+
async function teardown() {
|
|
90
|
+
if (stopping)
|
|
91
|
+
return stopping;
|
|
92
|
+
stopping = (async () => {
|
|
93
|
+
restoreConsole();
|
|
94
|
+
// Dev servers this process booted are its children and nothing else will
|
|
95
|
+
// reap them. A browser inside an in-flight scan cannot be reached from
|
|
96
|
+
// here, but Playwright launches Chromium on a CDP pipe — this process
|
|
97
|
+
// exiting closes it, and that is what shuts the browser down.
|
|
98
|
+
await runtime.closeAll().catch(() => { });
|
|
99
|
+
})();
|
|
100
|
+
return stopping;
|
|
101
|
+
}
|
|
102
|
+
function discoverResult() {
|
|
103
|
+
return {
|
|
104
|
+
supportedVersions: [...SUPPORTED_VERSIONS],
|
|
105
|
+
capabilities: { tools: {} },
|
|
106
|
+
// Server identity lives in `_meta` under this key, not in a top-level
|
|
107
|
+
// field — the shape is the spec's, and a client that reads it from
|
|
108
|
+
// anywhere else reads nothing.
|
|
109
|
+
_meta: { [META_SERVER_INFO]: serverInfo(VERSION) },
|
|
110
|
+
instructions: INSTRUCTIONS,
|
|
111
|
+
ttlMs: DISCOVER_TTL_MS,
|
|
112
|
+
cacheScope: "public",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The legacy handshake. Answering it at all is what makes this dual-era. */
|
|
116
|
+
function initializeResult(msg) {
|
|
117
|
+
const requested = msg.params?.["protocolVersion"];
|
|
118
|
+
const agreed = negotiate(requested);
|
|
119
|
+
if (!supports(requested)) {
|
|
120
|
+
io.error.write(`aztrx mcp: client asked for protocol version ${JSON.stringify(requested)}, ` +
|
|
121
|
+
`serving ${agreed}. Legacy clients have no fall-forward, so this may end the session.\n`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
protocolVersion: agreed,
|
|
125
|
+
// Exactly what we serve. Declaring `prompts`, `resources`, `logging` or
|
|
126
|
+
// `completions` would be a promise the code would break.
|
|
127
|
+
capabilities: { tools: {} },
|
|
128
|
+
serverInfo: serverInfo(VERSION),
|
|
129
|
+
instructions: INSTRUCTIONS,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function toolCall(id, msg) {
|
|
133
|
+
const name = msg.params?.["name"];
|
|
134
|
+
if (typeof name !== "string" || !name) {
|
|
135
|
+
throw new ProtocolError(INVALID_PARAMS, "tools/call requires a `name`");
|
|
136
|
+
}
|
|
137
|
+
const raw = msg.params?.["arguments"];
|
|
138
|
+
if (raw !== undefined && raw !== null && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
139
|
+
throw new ProtocolError(INVALID_PARAMS, "`arguments` must be an object");
|
|
140
|
+
}
|
|
141
|
+
const res = await runtime.call(name, (raw ?? {}));
|
|
142
|
+
return result(id, {
|
|
143
|
+
content: res.content,
|
|
144
|
+
structuredContent: res.structuredContent,
|
|
145
|
+
isError: res.isError === true,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async function route(msg) {
|
|
149
|
+
const id = msg.id ?? null;
|
|
150
|
+
switch (msg.method) {
|
|
151
|
+
case "initialize":
|
|
152
|
+
return result(id, initializeResult(msg));
|
|
153
|
+
case "server/discover":
|
|
154
|
+
return result(id, discoverResult());
|
|
155
|
+
case "tools/list":
|
|
156
|
+
// Deterministic order (the array is a literal) and cacheable. There is no
|
|
157
|
+
// pagination: three tools fit in one page, so `nextCursor` is never sent
|
|
158
|
+
// and a cursor from a client is ignored.
|
|
159
|
+
return result(id, { tools: TOOLS, ttlMs: TOOLS_TTL_MS, cacheScope: "public" });
|
|
160
|
+
case "tools/call":
|
|
161
|
+
return toolCall(id, msg);
|
|
162
|
+
// Removed by 2026-07-28, still sent by handshake-era clients as a liveness
|
|
163
|
+
// check. Answering costs nothing and a -32601 here reads to some of them as
|
|
164
|
+
// a dead server.
|
|
165
|
+
case "ping":
|
|
166
|
+
return result(id);
|
|
167
|
+
default:
|
|
168
|
+
throw new ProtocolError(METHOD_NOT_FOUND, `Method not found: ${msg.method}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function handleRequest(msg) {
|
|
172
|
+
const id = msg.id ?? null;
|
|
173
|
+
try {
|
|
174
|
+
return await route(msg);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
if (e instanceof ProtocolError)
|
|
178
|
+
return error(id, e.code, e.message, e.data);
|
|
179
|
+
io.error.write(`aztrx mcp: ${e.stack ?? String(e)}\n`);
|
|
180
|
+
return error(id, INTERNAL_ERROR, `Internal error: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// In-flight requests, by id, with the way to mark one abandoned.
|
|
184
|
+
const inflight = new Map();
|
|
185
|
+
const abandoned = new Set();
|
|
186
|
+
const keyOf = (id) => `${typeof id}:${String(id)}`;
|
|
187
|
+
/** How a stdio client abandons a request — a cancelled scan, a user who hit
|
|
188
|
+
* stop. A notification, so it gets no reply of its own, and the request it
|
|
189
|
+
* names gets none either: a response to a request the client has moved on from
|
|
190
|
+
* is worse than silence, because it desynchronises the id sequence. */
|
|
191
|
+
function cancelInFlight(msg) {
|
|
192
|
+
const raw = msg.params?.["requestId"];
|
|
193
|
+
if (typeof raw !== "string" && typeof raw !== "number")
|
|
194
|
+
return;
|
|
195
|
+
const key = keyOf(raw);
|
|
196
|
+
const cancel = inflight.get(key);
|
|
197
|
+
if (!cancel)
|
|
198
|
+
return;
|
|
199
|
+
abandoned.add(key);
|
|
200
|
+
cancel();
|
|
201
|
+
}
|
|
202
|
+
async function handleMessage(msg) {
|
|
203
|
+
if (isNotification(msg)) {
|
|
204
|
+
if (msg.method === "notifications/cancelled")
|
|
205
|
+
cancelInFlight(msg);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const versionRejected = checkVersion(msg);
|
|
209
|
+
if (versionRejected) {
|
|
210
|
+
send(versionRejected);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const key = keyOf(msg.id ?? null);
|
|
214
|
+
let markAbandoned;
|
|
215
|
+
const abandonedSignal = new Promise((resolve) => {
|
|
216
|
+
markAbandoned = () => resolve(null);
|
|
217
|
+
});
|
|
218
|
+
inflight.set(key, markAbandoned);
|
|
219
|
+
try {
|
|
220
|
+
// We stop *waiting*, we cannot stop the browser mid-walk — `run()` has no
|
|
221
|
+
// abort. The abandoned scan finishes in the background, its result is
|
|
222
|
+
// discarded, and its `finally` still stops the dev server it booted, so
|
|
223
|
+
// nothing is orphaned either way.
|
|
224
|
+
const response = await Promise.race([handleRequest(msg), abandonedSignal]);
|
|
225
|
+
if (response !== null && !abandoned.has(key))
|
|
226
|
+
send(response);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
inflight.delete(key);
|
|
230
|
+
abandoned.delete(key);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function handleLine(line) {
|
|
234
|
+
if (!line.trim())
|
|
235
|
+
return;
|
|
236
|
+
try {
|
|
237
|
+
const decoded = decode(line.trim());
|
|
238
|
+
if (!decoded.ok) {
|
|
239
|
+
send(decoded.response);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await handleMessage(decoded.msg);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
// Nothing above should throw, and the protocol channel must survive it if
|
|
246
|
+
// something does.
|
|
247
|
+
io.error.write(`aztrx mcp: unhandled error on a request: ${e.message}\n`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
// Reading
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
/** Feed the reader ourselves rather than using `readline`.
|
|
254
|
+
*
|
|
255
|
+
* The framing is newline-delimited JSON, which is a five-line buffer loop and
|
|
256
|
+
* gives exact control over what counts as a message. `readline` also runs
|
|
257
|
+
* terminal handling when its input is a TTY, which a protocol channel should
|
|
258
|
+
* never be subject to. A chunk that ends mid-line is carried over — plenty of
|
|
259
|
+
* clients write a request in more than one `write()`. */
|
|
260
|
+
let buffer = "";
|
|
261
|
+
const onData = (chunk) => {
|
|
262
|
+
buffer += chunk.toString();
|
|
263
|
+
let at;
|
|
264
|
+
while ((at = buffer.indexOf("\n")) >= 0) {
|
|
265
|
+
const line = buffer.slice(0, at);
|
|
266
|
+
buffer = buffer.slice(at + 1);
|
|
267
|
+
void handleLine(line);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const onEnd = () => {
|
|
271
|
+
void exitNow(0);
|
|
272
|
+
};
|
|
273
|
+
io.input.on("data", onData);
|
|
274
|
+
io.input.on("end", onEnd);
|
|
275
|
+
async function exitNow(code) {
|
|
276
|
+
await teardown();
|
|
277
|
+
// Only a real process exits. A test that called `startServer` with its own
|
|
278
|
+
// streams keeps running and asserts on what it captured.
|
|
279
|
+
if (real)
|
|
280
|
+
process.exit(code);
|
|
281
|
+
}
|
|
282
|
+
if (real) {
|
|
283
|
+
process.on("SIGTERM", () => void exitNow(143));
|
|
284
|
+
process.on("SIGINT", () => void exitNow(130));
|
|
285
|
+
}
|
|
286
|
+
io.error.write(`aztrx mcp ${VERSION} — serving ${TOOLS.length} tools on stdio\n`);
|
|
287
|
+
return {
|
|
288
|
+
runtime,
|
|
289
|
+
stop: async () => {
|
|
290
|
+
io.input.off("data", onData);
|
|
291
|
+
io.input.off("end", onEnd);
|
|
292
|
+
await teardown();
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aztrx-cli mcp install` — write the editor config that starts this server.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors the discipline of `hook install` (`src/hooks/index.ts`), with one
|
|
5
|
+
* difference that changes everything: an MCP config file is **shared with other
|
|
6
|
+
* servers**. A hook file is ours; `.mcp.json` is not. So this never writes a whole
|
|
7
|
+
* file, it edits one key inside one — read, parse, add, write back — and it
|
|
8
|
+
* refuses rather than clobbers when the file will not parse. A refused install is
|
|
9
|
+
* a message the user can act on; a clobbered config is their other three servers
|
|
10
|
+
* gone, discovered a week later.
|
|
11
|
+
*/
|
|
12
|
+
/** The key our server lives under. Also the marker we look for on uninstall. */
|
|
13
|
+
export declare const MCP_SERVER_NAME = "aztrx";
|
|
14
|
+
export interface EditorTarget {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Path relative to the repo root. */
|
|
18
|
+
file: string;
|
|
19
|
+
/** The key the servers live under. VS Code's differs, and conflating the two
|
|
20
|
+
* produces a file that editor reads as having no servers in it at all. */
|
|
21
|
+
key: string;
|
|
22
|
+
/** Only offered when this path exists. An editor the project does not use
|
|
23
|
+
* should be reported as skipped, not written to. */
|
|
24
|
+
marker?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const TARGETS: EditorTarget[];
|
|
27
|
+
/** What each editor runs.
|
|
28
|
+
*
|
|
29
|
+
* Pinned to this build's version, so upgrading the CLI is upgrading the server —
|
|
30
|
+
* a config that says `aztrx-cli@latest` would silently change under the user.
|
|
31
|
+
* `npx -y` because an editor spawns this from a fresh shell with no PATH set up
|
|
32
|
+
* for the project. */
|
|
33
|
+
export declare function serverEntry(version: string): Record<string, unknown>;
|
|
34
|
+
export type ConfigStatus = "written" | "unchanged" | "skipped" | "refused";
|
|
35
|
+
export interface ConfigOutcome {
|
|
36
|
+
target: EditorTarget;
|
|
37
|
+
status: ConfigStatus;
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function installInto(repoRoot: string, target: EditorTarget, version: string, force?: boolean): ConfigOutcome;
|
|
42
|
+
export declare function uninstallFrom(repoRoot: string, target: EditorTarget): ConfigOutcome;
|
|
43
|
+
export interface InstallSummary {
|
|
44
|
+
outcomes: ConfigOutcome[];
|
|
45
|
+
/** True when at least one file was written. */
|
|
46
|
+
changed: boolean;
|
|
47
|
+
/** True when every target was skipped or refused — nothing to tell the user
|
|
48
|
+
* about beyond what the individual messages already say. */
|
|
49
|
+
nothingDone: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function installMcp(repoRoot: string, version: string, force?: boolean): InstallSummary;
|
|
52
|
+
export declare function uninstallMcp(repoRoot: string): InstallSummary;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aztrx-cli mcp install` — write the editor config that starts this server.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors the discipline of `hook install` (`src/hooks/index.ts`), with one
|
|
5
|
+
* difference that changes everything: an MCP config file is **shared with other
|
|
6
|
+
* servers**. A hook file is ours; `.mcp.json` is not. So this never writes a whole
|
|
7
|
+
* file, it edits one key inside one — read, parse, add, write back — and it
|
|
8
|
+
* refuses rather than clobbers when the file will not parse. A refused install is
|
|
9
|
+
* a message the user can act on; a clobbered config is their other three servers
|
|
10
|
+
* gone, discovered a week later.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
/** The key our server lives under. Also the marker we look for on uninstall. */
|
|
15
|
+
export const MCP_SERVER_NAME = "aztrx";
|
|
16
|
+
export const TARGETS = [
|
|
17
|
+
// Always offered: `.mcp.json` at the repo root is the portable form, and a
|
|
18
|
+
// project that has not opened Claude Code yet can still commit one.
|
|
19
|
+
{ id: "claude-code", label: "Claude Code", file: ".mcp.json", key: "mcpServers" },
|
|
20
|
+
{
|
|
21
|
+
id: "cursor",
|
|
22
|
+
label: "Cursor",
|
|
23
|
+
file: ".cursor/mcp.json",
|
|
24
|
+
key: "mcpServers",
|
|
25
|
+
marker: ".cursor",
|
|
26
|
+
},
|
|
27
|
+
{ id: "vscode", label: "VS Code", file: ".vscode/mcp.json", key: "servers", marker: ".vscode" },
|
|
28
|
+
];
|
|
29
|
+
/** What each editor runs.
|
|
30
|
+
*
|
|
31
|
+
* Pinned to this build's version, so upgrading the CLI is upgrading the server —
|
|
32
|
+
* a config that says `aztrx-cli@latest` would silently change under the user.
|
|
33
|
+
* `npx -y` because an editor spawns this from a fresh shell with no PATH set up
|
|
34
|
+
* for the project. */
|
|
35
|
+
export function serverEntry(version) {
|
|
36
|
+
return { type: "stdio", command: "npx", args: ["-y", `aztrx-cli@${version}`, "mcp"] };
|
|
37
|
+
}
|
|
38
|
+
function isObject(v) {
|
|
39
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
40
|
+
}
|
|
41
|
+
function loadJson(abs) {
|
|
42
|
+
if (!fs.existsSync(abs))
|
|
43
|
+
return { ok: true, doc: {} };
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(abs, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { ok: false, error: `it cannot be read: ${e.message}` };
|
|
50
|
+
}
|
|
51
|
+
// An empty file is an empty config, not a syntax error — `.mcp.json` gets
|
|
52
|
+
// created by hand as often as by a tool.
|
|
53
|
+
if (!raw.trim())
|
|
54
|
+
return { ok: true, doc: {} };
|
|
55
|
+
let value;
|
|
56
|
+
try {
|
|
57
|
+
value = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// V8's message starts with a capital and is spliced into the middle of our
|
|
61
|
+
// sentence ("… exists and Expected double-quoted property name …").
|
|
62
|
+
const msg = e.message;
|
|
63
|
+
return { ok: false, error: msg.charAt(0).toLowerCase() + msg.slice(1) };
|
|
64
|
+
}
|
|
65
|
+
if (!isObject(value))
|
|
66
|
+
return { ok: false, error: "its top level is not a JSON object" };
|
|
67
|
+
return { ok: true, doc: value };
|
|
68
|
+
}
|
|
69
|
+
/** Copy the file aside before an overwrite we could not parse.
|
|
70
|
+
*
|
|
71
|
+
* Best effort on purpose. The refusal above is the safety; this is the kindness —
|
|
72
|
+
* a JSON syntax error is usually one missing comma, and the user's other servers
|
|
73
|
+
* are in there. */
|
|
74
|
+
function backup(abs) {
|
|
75
|
+
const dest = `${abs}.bak`;
|
|
76
|
+
try {
|
|
77
|
+
fs.copyFileSync(abs, dest);
|
|
78
|
+
return dest;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function writeJson(abs, doc) {
|
|
85
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
86
|
+
fs.writeFileSync(abs, JSON.stringify(doc, null, 2) + "\n", "utf-8");
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// install / uninstall
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
export function installInto(repoRoot, target, version, force = false) {
|
|
92
|
+
const abs = path.join(repoRoot, target.file);
|
|
93
|
+
if (target.marker && !fs.existsSync(path.join(repoRoot, target.marker))) {
|
|
94
|
+
return {
|
|
95
|
+
target,
|
|
96
|
+
status: "skipped",
|
|
97
|
+
path: abs,
|
|
98
|
+
message: `no ${target.marker}/ in this project`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const loaded = loadJson(abs);
|
|
102
|
+
// Unparseable and forced → start from empty rather than from the broken parse.
|
|
103
|
+
let doc = loaded.ok ? loaded.doc : {};
|
|
104
|
+
let note = "";
|
|
105
|
+
if (!loaded.ok) {
|
|
106
|
+
if (!force) {
|
|
107
|
+
return {
|
|
108
|
+
target,
|
|
109
|
+
status: "refused",
|
|
110
|
+
path: abs,
|
|
111
|
+
message: `${target.file} exists and ${loaded.error} — refusing to overwrite it, because any ` +
|
|
112
|
+
"other MCP servers in it would go too. Fix the file, or pass --force (which saves a " +
|
|
113
|
+
".bak first).",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const saved = backup(abs);
|
|
117
|
+
note = saved ? ` (previous contents saved to ${path.basename(saved)})` : "";
|
|
118
|
+
}
|
|
119
|
+
const existing = doc[target.key];
|
|
120
|
+
if (existing !== undefined && !isObject(existing)) {
|
|
121
|
+
if (!force) {
|
|
122
|
+
return {
|
|
123
|
+
target,
|
|
124
|
+
status: "refused",
|
|
125
|
+
path: abs,
|
|
126
|
+
message: `${target.file} has a \`${target.key}\` key that is not an object — refusing to overwrite it.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const saved = backup(abs);
|
|
130
|
+
note = saved ? ` (previous contents saved to ${path.basename(saved)})` : "";
|
|
131
|
+
// Drop just the malformed key. Everything else at the top level of the file
|
|
132
|
+
// — `$schema`, an editor's other settings — is still readable and still the
|
|
133
|
+
// user's, so only the key we cannot merge into is discarded.
|
|
134
|
+
delete doc[target.key];
|
|
135
|
+
}
|
|
136
|
+
const slot = doc[target.key];
|
|
137
|
+
const servers = isObject(slot) ? slot : {};
|
|
138
|
+
const hadOurs = MCP_SERVER_NAME in servers;
|
|
139
|
+
servers[MCP_SERVER_NAME] = serverEntry(version);
|
|
140
|
+
doc[target.key] = servers;
|
|
141
|
+
const next = JSON.stringify(doc, null, 2) + "\n";
|
|
142
|
+
const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf-8") : null;
|
|
143
|
+
if (current === next) {
|
|
144
|
+
return { target, status: "unchanged", path: abs, message: `${target.file} is already up to date` };
|
|
145
|
+
}
|
|
146
|
+
writeJson(abs, doc);
|
|
147
|
+
const others = Object.keys(servers).filter((k) => k !== MCP_SERVER_NAME).length;
|
|
148
|
+
const kept = others
|
|
149
|
+
? ` ${others} other server${others === 1 ? "" : "s"} in the file left untouched.`
|
|
150
|
+
: "";
|
|
151
|
+
return {
|
|
152
|
+
target,
|
|
153
|
+
status: "written",
|
|
154
|
+
path: abs,
|
|
155
|
+
message: `${target.file} — ${hadOurs ? "updated" : "added"}.${kept}${note}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export function uninstallFrom(repoRoot, target) {
|
|
159
|
+
const abs = path.join(repoRoot, target.file);
|
|
160
|
+
const rel = target.file;
|
|
161
|
+
if (!fs.existsSync(abs)) {
|
|
162
|
+
return { target, status: "skipped", path: abs, message: `no ${rel}` };
|
|
163
|
+
}
|
|
164
|
+
const loaded = loadJson(abs);
|
|
165
|
+
if (!loaded.ok) {
|
|
166
|
+
return {
|
|
167
|
+
target,
|
|
168
|
+
status: "refused",
|
|
169
|
+
path: abs,
|
|
170
|
+
message: `${rel} ${loaded.error} — leaving it alone.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const servers = loaded.doc[target.key];
|
|
174
|
+
if (!isObject(servers) || !(MCP_SERVER_NAME in servers)) {
|
|
175
|
+
return { target, status: "skipped", path: abs, message: `no aztrx entry in ${rel}` };
|
|
176
|
+
}
|
|
177
|
+
delete servers[MCP_SERVER_NAME];
|
|
178
|
+
// The file stays even when that leaves the map empty. Deleting a file we did
|
|
179
|
+
// not create, on the strength of it looking empty afterwards, is a worse
|
|
180
|
+
// failure than four bytes of leftover JSON.
|
|
181
|
+
writeJson(abs, loaded.doc);
|
|
182
|
+
return {
|
|
183
|
+
target,
|
|
184
|
+
status: "written",
|
|
185
|
+
path: abs,
|
|
186
|
+
message: `${rel} — aztrx entry removed (the file is kept: other servers may live in it).`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function installMcp(repoRoot, version, force = false) {
|
|
190
|
+
const outcomes = TARGETS.map((t) => installInto(repoRoot, t, version, force));
|
|
191
|
+
return {
|
|
192
|
+
outcomes,
|
|
193
|
+
changed: outcomes.some((o) => o.status === "written"),
|
|
194
|
+
nothingDone: outcomes.every((o) => o.status === "skipped" || o.status === "refused"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export function uninstallMcp(repoRoot) {
|
|
198
|
+
const outcomes = TARGETS.map((t) => uninstallFrom(repoRoot, t));
|
|
199
|
+
return {
|
|
200
|
+
outcomes,
|
|
201
|
+
changed: outcomes.some((o) => o.status === "written"),
|
|
202
|
+
nothingDone: outcomes.every((o) => o.status === "skipped" || o.status === "refused"),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP wire format — framing, version negotiation, result envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled, deliberately. Revisions `2025-11-25` and earlier open with an
|
|
5
|
+
* `initialize` handshake; `2026-07-28` removed it in favour of per-request
|
|
6
|
+
* metadata. Editors are split across the two (Cursor still opens with
|
|
7
|
+
* `initialize`), and neither official SDK speaks both: `@modelcontextprotocol/sdk`
|
|
8
|
+
* is legacy-only and pulls 17 runtime dependencies — express, hono, cors, jose,
|
|
9
|
+
* ajv — for a stdio server that needs none of them, and
|
|
10
|
+
* `@modelcontextprotocol/server` is the new revision only. A server that speaks
|
|
11
|
+
* one era is a server that is missing from somebody's editor. The spec has a name
|
|
12
|
+
* for the thing that speaks both — **dual-era** — and rules for it, which this
|
|
13
|
+
* follows rather than reinvents.
|
|
14
|
+
*
|
|
15
|
+
* This file imports nothing at all. It is loaded before the first request from
|
|
16
|
+
* an editor, and editors time out servers that boot slowly.
|
|
17
|
+
*/
|
|
18
|
+
/** Every revision this server can serve, oldest first. `2026-07-28` is the one
|
|
19
|
+
* that made `server/discover` mandatory and `resultType` required; the earlier
|
|
20
|
+
* four are the handshake era, and `2025-06-18` / `2025-11-25` are what the
|
|
21
|
+
* editors in the wild actually send today. */
|
|
22
|
+
export declare const SUPPORTED_VERSIONS: readonly ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"];
|
|
23
|
+
/** Newest revision implemented. Used only when we cannot serve what was asked
|
|
24
|
+
* for — never to "upgrade" a client that asked for something older. */
|
|
25
|
+
export declare const LATEST_VERSION: string;
|
|
26
|
+
/** `_meta` keys, per the `_meta` naming rules in the 2026-07-28 base spec. */
|
|
27
|
+
export declare const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
28
|
+
export declare const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
29
|
+
export declare const PARSE_ERROR = -32700;
|
|
30
|
+
export declare const INVALID_REQUEST = -32600;
|
|
31
|
+
export declare const METHOD_NOT_FOUND = -32601;
|
|
32
|
+
export declare const INVALID_PARAMS = -32602;
|
|
33
|
+
export declare const INTERNAL_ERROR = -32603;
|
|
34
|
+
/** `UnsupportedProtocolVersionError`, per the 2026-07-28 error-code table. The
|
|
35
|
+
* number is not decorative: a modern client matches on it to tell "this server
|
|
36
|
+
* speaks my language but not this dialect — retry with one off `supported`"
|
|
37
|
+
* from "this server is old", and the two lead to opposite actions. */
|
|
38
|
+
export declare const UNSUPPORTED_PROTOCOL_VERSION = -32022;
|
|
39
|
+
export type RpcId = string | number | null;
|
|
40
|
+
export interface RpcMessage {
|
|
41
|
+
jsonrpc?: string;
|
|
42
|
+
id?: RpcId;
|
|
43
|
+
method?: string;
|
|
44
|
+
params?: Record<string, unknown>;
|
|
45
|
+
result?: unknown;
|
|
46
|
+
error?: unknown;
|
|
47
|
+
}
|
|
48
|
+
/** A failure of the *request* — bad method, bad shape. Distinct from a tool that
|
|
49
|
+
* ran and reported a problem, which is a normal result with `isError: true`. */
|
|
50
|
+
export declare class ProtocolError extends Error {
|
|
51
|
+
readonly code: number;
|
|
52
|
+
readonly data?: unknown | undefined;
|
|
53
|
+
constructor(code: number, message: string, data?: unknown | undefined);
|
|
54
|
+
}
|
|
55
|
+
/** One JSON-RPC message, one line. That is the stdio binding, and `JSON.stringify`
|
|
56
|
+
* already guarantees it — a newline inside a string is escaped, never emitted raw. */
|
|
57
|
+
export declare function encode(message: unknown): string;
|
|
58
|
+
export type Decoded = {
|
|
59
|
+
ok: true;
|
|
60
|
+
msg: RpcMessage;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
response: RpcMessage;
|
|
64
|
+
};
|
|
65
|
+
/** Parse one line. A line that is not JSON has an unknowable id, so the error
|
|
66
|
+
* goes out with a null one — the JSON-RPC rule for exactly this case. */
|
|
67
|
+
export declare function decode(line: string): Decoded;
|
|
68
|
+
/** A request expects exactly one response; a notification expects none, and
|
|
69
|
+
* answering one is a protocol violation. `id` is what separates them — a
|
|
70
|
+
* notification simply has no `id` member. */
|
|
71
|
+
export declare function isNotification(msg: RpcMessage): boolean;
|
|
72
|
+
export declare function supports(version: unknown): version is string;
|
|
73
|
+
/** The version a *modern* request declares in `_meta`, or undefined for a legacy
|
|
74
|
+
* request (which declares nothing — its version was fixed by `initialize`). */
|
|
75
|
+
export declare function declaredVersion(msg: RpcMessage): string | undefined;
|
|
76
|
+
/** Which version to answer a legacy `initialize` with.
|
|
77
|
+
*
|
|
78
|
+
* The legacy spec makes this a **MUST**, not a courtesy: *"If the server supports
|
|
79
|
+
* the requested protocol version, it MUST respond with the same version.
|
|
80
|
+
* Otherwise, the server MUST respond with another protocol version it supports."*
|
|
81
|
+
* So we echo what was asked for whenever we can serve it. Answering `initialize`
|
|
82
|
+
* with our newest version when the client asked for an older one we support is a
|
|
83
|
+
* spec violation, and it is the documented way to make a modern client refuse the
|
|
84
|
+
* server outright ("Server's protocol version is not supported") — the client has
|
|
85
|
+
* no fall-forward mechanism, so a needless mismatch is fatal, not merely noisy. */
|
|
86
|
+
export declare function negotiate(requested: unknown): string;
|
|
87
|
+
/** Reject a request that declares a version we do not implement. Returns a
|
|
88
|
+
* ready-to-send error, or null when the request may proceed.
|
|
89
|
+
*
|
|
90
|
+
* Only modern requests declare a version; a legacy request carries none and is
|
|
91
|
+
* served under legacy semantics for the life of the process. The `data` shape is
|
|
92
|
+
* exact because it is load-bearing: the client picks a version off `supported`
|
|
93
|
+
* and retries, so an inexact copy costs it the retry it needs. */
|
|
94
|
+
export declare function checkVersion(msg: RpcMessage): RpcMessage | null;
|
|
95
|
+
/** A successful result.
|
|
96
|
+
*
|
|
97
|
+
* `resultType: "complete"` is required on every result from `2026-07-28` on, and
|
|
98
|
+
* older clients ignore fields they do not recognise — so it is written
|
|
99
|
+
* unconditionally rather than behind an era check. One response shape for every
|
|
100
|
+
* era is the whole reason this server can serve both. */
|
|
101
|
+
export declare function result(id: RpcId, payload?: Record<string, unknown>): RpcMessage;
|
|
102
|
+
export declare function error(id: RpcId, code: number, message: string, data?: unknown): RpcMessage;
|
|
103
|
+
export declare function serverInfo(version: string): {
|
|
104
|
+
name: string;
|
|
105
|
+
version: string;
|
|
106
|
+
};
|