@impetik/xeer-mcp 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Impetik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @impetik/xeer-mcp
2
+
3
+ Model Context Protocol server for [Xeer](https://github.com/impetik/xeer). It exposes the
4
+ scaffold → check → dev → build → deploy loop as MCP tools, each returning the
5
+ `xeer.command.v0` JSON envelope the `xeer` CLI already prints.
6
+
7
+ See [docs/AGENTS.md](https://github.com/impetik/xeer/blob/main/docs/AGENTS.md) for the loop
8
+ this server is built for, and the `xeer` skill in
9
+ [`.claude/skills/xeer/`](https://github.com/impetik/xeer/tree/main/.claude/skills/xeer) for
10
+ the same protocol written as agent instructions.
11
+
12
+ ## Run it
13
+
14
+ ```jsonc
15
+ // .mcp.json, or your harness's MCP configuration
16
+ {
17
+ "mcpServers": {
18
+ "xeer": {
19
+ "command": "npx",
20
+ "args": ["--package=@impetik/xeer-mcp", "--", "xeer-mcp"],
21
+ "env": { "XEER_MCP_ROOT": "." }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ From this repository, after `pnpm -r build`:
28
+
29
+ ```sh
30
+ node packages/mcp/dist/main.js # stdio; or `pnpm mcp` from the workspace root
31
+ ```
32
+
33
+ ## Tools
34
+
35
+ | Tool | CLI it runs | Returns |
36
+ | --- | --- | --- |
37
+ | `xeer_check` | `xeer check <dir> --json` | Manifest + analysis diagnostics; `result.manifest` when clean |
38
+ | `xeer_test` | `xeer test <dir> --json` | `{ ok, total, passed, failed, cases, diagnostics, events }` |
39
+ | `xeer_build` | `xeer build <dir> --json` | `result.artifactId`, modules, assets, operations |
40
+ | `xeer_new` | `xeer new <dir> --json` | `result.files` |
41
+ | `xeer_doctor` | `xeer doctor <dir> --json` | `result.checks`, `result.summary` |
42
+ | `xeer_deploy` | `xeer deploy <dir> --json` | `result.url` |
43
+ | `xeer_auth_status` | `xeer auth status --json` | Builder identity and credential expiry |
44
+ | `xeer_inspect` | `xeer inspect`/`state`/`logs` | Inspector response for a running preview |
45
+ | `xeer_dev_start` | `xeer dev <dir> --json --port 0` | Session handle, cursor, and the events up to `preview.ready` |
46
+ | `xeer_dev_status` | — | `xeer.dev.v0` events after a cursor, plus `openDiagnostics` |
47
+ | `xeer_dev_stop` | — | Final events; shuts down over `xeer.dev.control.v0` IPC |
48
+ | `xeer_diagnostics` | — | What an `XE####` code means and the edit that closes it |
49
+
50
+ Every command tool returns the envelope both as `structuredContent` and as a JSON text
51
+ block, so a client that ignores structured output still sees all of it. A tool result is
52
+ always an envelope: when the CLI crashes without printing one, the server synthesizes
53
+ `XE0000` rather than failing the call in a different shape.
54
+
55
+ `xeer_test` is the exception, because `xeer test --json` streams `xeer.dev.v0` events rather
56
+ than printing an envelope. It collects the run and returns the summary plus the unabridged
57
+ event stream, with each failed case carrying a `Diagnostic`-shaped `failure` (`XE1904`
58
+ assertion, `XE1905` throw or unexpectedly refused call, `XE1906` timeout) that includes the
59
+ matcher, expected and actual values, and the project-relative test location. It needs no
60
+ session handle: unlike `dev`, a test run is short-lived and terminal.
61
+
62
+ `xeer link` and `xeer env` are deliberately absent. Which hosted app a checkout deploys to
63
+ is the human owner's decision, so on `XE5111`/`XE5120`/`XE5121` the surface reports the
64
+ diagnostic and its documented repair instead of performing it. `xeer env` reads and writes
65
+ secrets — `xeer env pull` returns plaintext — so it is not reachable through a tool call.
66
+
67
+ ## Why dev is three tools
68
+
69
+ An MCP tool call is request/response; `xeer dev` is a long-lived JSONL stream. The session
70
+ is therefore owned by the server and addressed by a `sessionId`, with the protocol's own
71
+ `seq` as a resumable cursor:
72
+
73
+ ```text
74
+ xeer_dev_start → { sessionId, cursor, status: 'ready', preview: { url, inspectorUrl, ... }, events }
75
+ edit a file
76
+ xeer_dev_status → { events: [compile.start, compile.diagnostic?, compile.ready?], openDiagnostics, generation }
77
+ xeer_dev_stop → { status: 'stopped' }
78
+ ```
79
+
80
+ No event is summarized away, so the agent reads the same stream a terminal would show.
81
+ Repairing diagnostics needs none of this — `xeer_check` runs the same compiler — so the
82
+ session exists for when the preview, the inspector, or rebuild/rollback behaviour matters.
83
+
84
+ ## Configuration
85
+
86
+ | Variable | Effect |
87
+ | --- | --- |
88
+ | `XEER_MCP_ROOT` | Confinement root for every `directory` argument. Defaults to the server's working directory; paths that escape it are refused. |
89
+ | `XEER_CLI` | Absolute path to `dist/cli.js`, overriding resolution through this package's dependencies. |
90
+
91
+ Always stop dev sessions you start: local state is leased per project and mode, so a leaked
92
+ session makes the next run fail with `XE1812`. The stdio entrypoint stops every session on
93
+ `SIGINT`/`SIGTERM`.
@@ -0,0 +1,92 @@
1
+ import type { Diagnostic, DevEvent } from '@impetik/xeer-spec';
2
+ /**
3
+ * `xeer dev` is a long-running process that streams `xeer.dev.v0` JSONL events;
4
+ * an MCP tool call is a single request/response. This module reconciles the two
5
+ * without weakening either.
6
+ *
7
+ * The process is owned by the server and addressed by a session id. Events are
8
+ * buffered with their protocol `seq` as the cursor, so `xeer_dev_status` is a
9
+ * resumable read of the same stream a terminal would have shown — no event is
10
+ * summarized away, and nothing is lost between polls. Shutdown uses the
11
+ * documented `xeer.dev.control.v0` IPC message rather than a signal, which is
12
+ * why the child is forked with an IPC channel.
13
+ *
14
+ * The alternative — re-running `xeer check` after every edit — is a complete
15
+ * repair loop on its own and is what the skill recommends for pure diagnostics
16
+ * work. A dev session is for when the agent also needs the preview, the
17
+ * inspector, and the rebuild/rollback behaviour that only `dev` has.
18
+ */
19
+ export type DevSessionStatus = 'starting' | 'ready' | 'compile_failed' | 'stopped' | 'crashed';
20
+ export interface PreviewUrls {
21
+ url: string;
22
+ healthUrl: string;
23
+ inspectorUrl: string;
24
+ debugUrl: string;
25
+ logsUrl: string;
26
+ }
27
+ export interface DevSessionSummary {
28
+ sessionId: string;
29
+ directory: string;
30
+ status: DevSessionStatus;
31
+ running: boolean;
32
+ /** Highest `seq` observed. Pass it back as `cursor` to read only what is new. */
33
+ cursor: number;
34
+ generation?: number;
35
+ artifactId?: string;
36
+ preview?: PreviewUrls;
37
+ /** Diagnostics from the most recent compile attempt. Empty once a rebuild is accepted. */
38
+ openDiagnostics: Diagnostic[];
39
+ exit?: {
40
+ code?: number;
41
+ reason?: string;
42
+ };
43
+ /** Number of buffered events discarded because the buffer is bounded. */
44
+ droppedEvents: number;
45
+ stderr?: string;
46
+ }
47
+ export interface DevSessionRead extends DevSessionSummary {
48
+ events: DevEvent[];
49
+ }
50
+ export interface DevSessionOptions {
51
+ directory: string;
52
+ host?: string;
53
+ port?: number;
54
+ }
55
+ export declare class DevSession {
56
+ #private;
57
+ readonly sessionId: string;
58
+ readonly directory: string;
59
+ constructor(sessionId: string, options: DevSessionOptions);
60
+ get running(): boolean;
61
+ get status(): DevSessionStatus;
62
+ /**
63
+ * Resolves once the session is serving or the process is gone. A failed first
64
+ * compile emits `process.exit` and then exits, so waiting for the process
65
+ * rather than for the event keeps `running` truthful in the returned summary.
66
+ */
67
+ waitForStart(timeoutMilliseconds?: number): Promise<void>;
68
+ /**
69
+ * Waits for the burst of events after `cursor` to settle.
70
+ *
71
+ * Two conditions, because a rebuild has two shapes. A compile that is in
72
+ * flight must be waited *out* — `compile.start` is followed by seconds of
73
+ * silence while the compiler works, so returning on a quiet window there would
74
+ * report "no diagnostics" for a build that had not finished. A compile that
75
+ * failed emits its diagnostics and then simply stops, with no terminal event,
76
+ * so once diagnostics have arrived a quiet window is the only signal there is.
77
+ */
78
+ waitForActivity(cursor: number, timeoutMilliseconds: number, quietMilliseconds?: number): Promise<void>;
79
+ summary(): DevSessionSummary;
80
+ read(cursor?: number, limit?: number): DevSessionRead;
81
+ /** Uses the documented IPC shutdown, then escalates only if the child ignores it. */
82
+ stop(): Promise<DevSessionSummary>;
83
+ }
84
+ export declare class DevSessionRegistry {
85
+ #private;
86
+ start(options: DevSessionOptions): DevSession;
87
+ find(directory: string): DevSession | undefined;
88
+ get(sessionId?: string): DevSession;
89
+ list(): string[];
90
+ summaries(): DevSessionSummary[];
91
+ stopAll(): Promise<void>;
92
+ }
@@ -0,0 +1,285 @@
1
+ import { fork } from 'node:child_process';
2
+ import { resolveXeerCli } from './xeer-cli.js';
3
+ const MAX_BUFFERED_EVENTS = 2_000;
4
+ const MAX_STDERR_CHARACTERS = 4_096;
5
+ const DEFAULT_START_TIMEOUT_MILLISECONDS = 180_000;
6
+ const DEFAULT_QUIET_MILLISECONDS = 600;
7
+ const STOP_TIMEOUT_MILLISECONDS = 20_000;
8
+ export class DevSession {
9
+ sessionId;
10
+ directory;
11
+ #child;
12
+ #events = [];
13
+ #dropped = 0;
14
+ #cursor = 0;
15
+ #status = 'starting';
16
+ #preview;
17
+ #generation;
18
+ #artifactId;
19
+ #openDiagnostics = [];
20
+ #exit;
21
+ /** A compile is in flight: an agent polling for the outcome must keep waiting. */
22
+ #compiling = false;
23
+ #restarting = false;
24
+ #diagnosticsThisCompile = 0;
25
+ #stderr = '';
26
+ #stdoutRemainder = '';
27
+ #waiters = new Set();
28
+ constructor(sessionId, options) {
29
+ this.sessionId = sessionId;
30
+ this.directory = options.directory;
31
+ // Port 0 is the documented automation mode: the OS picks a free port and the
32
+ // preview.ready event reports it, so parallel sessions cannot collide.
33
+ const args = ['dev', options.directory, '--json',
34
+ '--host', options.host ?? '127.0.0.1', '--port', String(options.port ?? 0)];
35
+ this.#child = fork(resolveXeerCli(), args, {
36
+ cwd: options.directory,
37
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
38
+ env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
39
+ });
40
+ this.#child.stdout?.setEncoding('utf8');
41
+ this.#child.stderr?.setEncoding('utf8');
42
+ this.#child.stdout?.on('data', (chunk) => this.#ingest(chunk));
43
+ this.#child.stderr?.on('data', (chunk) => {
44
+ this.#stderr = (this.#stderr + chunk).slice(-MAX_STDERR_CHARACTERS);
45
+ this.#wake();
46
+ });
47
+ this.#child.on('error', (error) => {
48
+ this.#stderr = (this.#stderr + `\n${error.message}`).slice(-MAX_STDERR_CHARACTERS);
49
+ if (this.running)
50
+ this.#status = 'crashed';
51
+ this.#wake();
52
+ });
53
+ this.#child.on('close', (code) => {
54
+ // `process.exit` already recorded the intent; a close without it is a crash.
55
+ if (this.#status === 'starting' || this.#status === 'ready') {
56
+ this.#status = code === 0 ? 'stopped' : 'crashed';
57
+ }
58
+ this.#exit = { ...(typeof code === 'number' ? { code } : {}), ...(this.#exit?.reason ? { reason: this.#exit.reason } : {}) };
59
+ this.#wake();
60
+ });
61
+ }
62
+ get running() {
63
+ return this.#child.exitCode === null && this.#child.signalCode === null;
64
+ }
65
+ get status() {
66
+ return this.#status;
67
+ }
68
+ #ingest(chunk) {
69
+ const text = this.#stdoutRemainder + chunk;
70
+ const lines = text.split('\n');
71
+ this.#stdoutRemainder = lines.pop() ?? '';
72
+ for (const line of lines) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed)
75
+ continue;
76
+ let event;
77
+ try {
78
+ const parsed = JSON.parse(trimmed);
79
+ if (parsed?.protocol !== 'xeer.dev.v0' || typeof parsed.type !== 'string')
80
+ continue;
81
+ event = parsed;
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ this.#record(event);
87
+ }
88
+ this.#wake();
89
+ }
90
+ #record(event) {
91
+ this.#events.push(event);
92
+ if (this.#events.length > MAX_BUFFERED_EVENTS) {
93
+ this.#dropped += this.#events.length - MAX_BUFFERED_EVENTS;
94
+ this.#events = this.#events.slice(-MAX_BUFFERED_EVENTS);
95
+ }
96
+ this.#cursor = Math.max(this.#cursor, event.seq);
97
+ // Unknown event types are ignored by contract; only the lifecycle types below
98
+ // change what an agent should do next.
99
+ switch (event.type) {
100
+ case 'compile.start':
101
+ this.#openDiagnostics = [];
102
+ this.#compiling = true;
103
+ this.#restarting = false;
104
+ this.#diagnosticsThisCompile = 0;
105
+ break;
106
+ case 'compile.diagnostic':
107
+ this.#openDiagnostics.push(event.data);
108
+ this.#diagnosticsThisCompile += 1;
109
+ break;
110
+ case 'compile.ready': {
111
+ const data = event.data;
112
+ this.#generation = data.generation;
113
+ this.#artifactId = data.artifactId;
114
+ this.#openDiagnostics = [];
115
+ // A guarded restart emits compile.ready before server.restarted, so the
116
+ // promotion is not finished yet.
117
+ if (!this.#restarting)
118
+ this.#compiling = false;
119
+ break;
120
+ }
121
+ case 'server.restarting':
122
+ this.#restarting = true;
123
+ this.#compiling = true;
124
+ break;
125
+ case 'server.restarted':
126
+ this.#restarting = false;
127
+ this.#compiling = false;
128
+ break;
129
+ case 'preview.ready':
130
+ this.#preview = event.data;
131
+ this.#status = 'ready';
132
+ this.#compiling = false;
133
+ break;
134
+ case 'process.exit': {
135
+ const data = event.data;
136
+ this.#exit = { ...(typeof data.code === 'number' ? { code: data.code } : {}), ...(data.reason ? { reason: data.reason } : {}) };
137
+ this.#status = data.reason === 'compile_failed' ? 'compile_failed'
138
+ : data.reason === 'runtime_failed' ? 'crashed' : 'stopped';
139
+ this.#compiling = false;
140
+ break;
141
+ }
142
+ default:
143
+ break;
144
+ }
145
+ }
146
+ #wake() {
147
+ for (const waiter of [...this.#waiters])
148
+ waiter();
149
+ }
150
+ #wait(predicate, timeoutMilliseconds) {
151
+ if (predicate())
152
+ return Promise.resolve();
153
+ return new Promise((done) => {
154
+ const finish = () => {
155
+ clearTimeout(timer);
156
+ this.#waiters.delete(waiter);
157
+ done();
158
+ };
159
+ const waiter = () => { if (predicate())
160
+ finish(); };
161
+ const timer = setTimeout(finish, timeoutMilliseconds);
162
+ this.#waiters.add(waiter);
163
+ });
164
+ }
165
+ /**
166
+ * Resolves once the session is serving or the process is gone. A failed first
167
+ * compile emits `process.exit` and then exits, so waiting for the process
168
+ * rather than for the event keeps `running` truthful in the returned summary.
169
+ */
170
+ async waitForStart(timeoutMilliseconds = DEFAULT_START_TIMEOUT_MILLISECONDS) {
171
+ await this.#wait(() => this.#status === 'ready' || !this.running, timeoutMilliseconds);
172
+ }
173
+ /**
174
+ * Waits for the burst of events after `cursor` to settle.
175
+ *
176
+ * Two conditions, because a rebuild has two shapes. A compile that is in
177
+ * flight must be waited *out* — `compile.start` is followed by seconds of
178
+ * silence while the compiler works, so returning on a quiet window there would
179
+ * report "no diagnostics" for a build that had not finished. A compile that
180
+ * failed emits its diagnostics and then simply stops, with no terminal event,
181
+ * so once diagnostics have arrived a quiet window is the only signal there is.
182
+ */
183
+ async waitForActivity(cursor, timeoutMilliseconds, quietMilliseconds = DEFAULT_QUIET_MILLISECONDS) {
184
+ if (timeoutMilliseconds <= 0)
185
+ return;
186
+ const deadline = Date.now() + timeoutMilliseconds;
187
+ const remaining = () => Math.max(0, deadline - Date.now());
188
+ await this.#wait(() => this.#cursor > cursor || !this.running, timeoutMilliseconds);
189
+ while (this.running && this.#cursor > cursor && remaining() > 0) {
190
+ if (this.#compiling && this.#diagnosticsThisCompile === 0) {
191
+ await this.#wait(() => !this.#compiling || this.#diagnosticsThisCompile > 0, remaining());
192
+ continue;
193
+ }
194
+ const before = this.#cursor;
195
+ await this.#wait(() => this.#cursor > before, Math.min(quietMilliseconds, remaining()));
196
+ if (this.#cursor === before)
197
+ return;
198
+ }
199
+ }
200
+ summary() {
201
+ return {
202
+ sessionId: this.sessionId,
203
+ directory: this.directory,
204
+ status: this.#status,
205
+ running: this.running,
206
+ cursor: this.#cursor,
207
+ ...(this.#generation === undefined ? {} : { generation: this.#generation }),
208
+ ...(this.#artifactId === undefined ? {} : { artifactId: this.#artifactId }),
209
+ ...(this.#preview ? { preview: this.#preview } : {}),
210
+ openDiagnostics: [...this.#openDiagnostics],
211
+ ...(this.#exit ? { exit: this.#exit } : {}),
212
+ droppedEvents: this.#dropped,
213
+ ...(this.#stderr.trim() ? { stderr: this.#stderr.trim() } : {}),
214
+ };
215
+ }
216
+ read(cursor = 0, limit = 200) {
217
+ const events = this.#events.filter((event) => event.seq > cursor);
218
+ return {
219
+ ...this.summary(),
220
+ events: events.length > limit ? events.slice(-limit) : events,
221
+ };
222
+ }
223
+ /** Uses the documented IPC shutdown, then escalates only if the child ignores it. */
224
+ async stop() {
225
+ if (!this.running)
226
+ return this.summary();
227
+ if (this.#child.connected)
228
+ this.#child.send({ protocol: 'xeer.dev.control.v0', type: 'shutdown' });
229
+ else
230
+ this.#child.kill('SIGTERM');
231
+ await this.#wait(() => !this.running, STOP_TIMEOUT_MILLISECONDS);
232
+ if (this.running) {
233
+ this.#child.kill('SIGKILL');
234
+ await this.#wait(() => !this.running, 5_000);
235
+ }
236
+ return this.summary();
237
+ }
238
+ }
239
+ export class DevSessionRegistry {
240
+ #sessions = new Map();
241
+ #counter = 0;
242
+ start(options) {
243
+ const existing = this.find(options.directory);
244
+ if (existing?.running) {
245
+ throw new Error(`A dev session is already running for ${options.directory} (${existing.sessionId}). `
246
+ + 'Local state is leased per project, so stop it before starting another.');
247
+ }
248
+ if (existing)
249
+ this.#sessions.delete(existing.sessionId);
250
+ this.#counter += 1;
251
+ const session = new DevSession(`dev-${this.#counter}`, options);
252
+ this.#sessions.set(session.sessionId, session);
253
+ return session;
254
+ }
255
+ find(directory) {
256
+ for (const session of this.#sessions.values())
257
+ if (session.directory === directory)
258
+ return session;
259
+ return undefined;
260
+ }
261
+ get(sessionId) {
262
+ if (sessionId) {
263
+ const session = this.#sessions.get(sessionId);
264
+ if (!session)
265
+ throw new Error(`Unknown dev session: ${sessionId}. Known: ${this.list().join(', ') || 'none'}`);
266
+ return session;
267
+ }
268
+ const running = [...this.#sessions.values()].filter((session) => session.running);
269
+ const candidates = running.length ? running : [...this.#sessions.values()];
270
+ if (candidates.length === 1)
271
+ return candidates[0];
272
+ if (!candidates.length)
273
+ throw new Error('No dev session has been started. Call xeer_dev_start first.');
274
+ throw new Error(`Several dev sessions exist; pass sessionId. Known: ${this.list().join(', ')}`);
275
+ }
276
+ list() {
277
+ return [...this.#sessions.keys()];
278
+ }
279
+ summaries() {
280
+ return [...this.#sessions.values()].map((session) => session.summary());
281
+ }
282
+ async stopAll() {
283
+ await Promise.all([...this.#sessions.values()].map((session) => session.stop()));
284
+ }
285
+ }
@@ -0,0 +1,4 @@
1
+ export { createXeerMcpServer, type XeerMcpServer } from './server.js';
2
+ export { DevSession, DevSessionRegistry, type DevSessionOptions, type DevSessionRead, type DevSessionStatus, type DevSessionSummary, type PreviewUrls, } from './dev-session.js';
3
+ export { runXeerTests, type RunTestsOptions, type TestCaseResult, type TestFailure, type TestRunResult, } from './test-run.js';
4
+ export { projectRoot, resolveDirectory, resolveXeerCli, runXeerCommand, XeerCliError, type CommandEnvelope, type CommandRun, type RunOptions, } from './xeer-cli.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { createXeerMcpServer } from './server.js';
2
+ export { DevSession, DevSessionRegistry, } from './dev-session.js';
3
+ export { runXeerTests, } from './test-run.js';
4
+ export { projectRoot, resolveDirectory, resolveXeerCli, runXeerCommand, XeerCliError, } from './xeer-cli.js';
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createXeerMcpServer } from './server.js';
4
+ /**
5
+ * stdio entrypoint. Nothing may be written to stdout except JSON-RPC frames, so
6
+ * every diagnostic message from this process goes to stderr.
7
+ */
8
+ const { server, devSessions } = createXeerMcpServer();
9
+ let closing = false;
10
+ async function shutdown(code) {
11
+ if (closing)
12
+ return;
13
+ closing = true;
14
+ // Dev sessions hold a local state lease; a leaked child would block the next run.
15
+ await devSessions.stopAll().catch(() => undefined);
16
+ await server.close().catch(() => undefined);
17
+ process.exit(code);
18
+ }
19
+ process.on('SIGINT', () => void shutdown(0));
20
+ process.on('SIGTERM', () => void shutdown(0));
21
+ try {
22
+ await server.connect(new StdioServerTransport());
23
+ }
24
+ catch (error) {
25
+ process.stderr.write(`xeer-mcp failed to start: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { DevSessionRegistry } from './dev-session.js';
3
+ export interface XeerMcpServer {
4
+ server: McpServer;
5
+ devSessions: DevSessionRegistry;
6
+ }
7
+ export declare function createXeerMcpServer(): XeerMcpServer;
package/dist/server.js ADDED
@@ -0,0 +1,376 @@
1
+ import { createRequire } from 'node:module';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { z } from 'zod';
4
+ import { DIAGNOSTICS, DIAGNOSTIC_CODES, DIAGNOSTIC_FAMILIES, diagnosticDefinition, renderDiagnosticsReference, } from '@impetik/xeer-spec/diagnostics';
5
+ import { DevSessionRegistry } from './dev-session.js';
6
+ import { runXeerTests } from './test-run.js';
7
+ import { projectRoot, resolveDirectory, runXeerCommand, XeerCliError } from './xeer-cli.js';
8
+ /**
9
+ * The Xeer MCP server.
10
+ *
11
+ * Tools are a thin, honest projection of the CLI: one tool per command, each
12
+ * returning the `xeer.command.v0` envelope the CLI printed. The only tools that
13
+ * are not a single command are the dev-session trio, because a long-running
14
+ * event stream has to be addressed by a handle and a cursor, and
15
+ * `xeer_diagnostics`, which serves the generated catalogue so an agent can look
16
+ * up a code without a second round trip through the filesystem.
17
+ */
18
+ const version = createRequire(import.meta.url)('../package.json').version;
19
+ const directoryArgument = z.string().optional()
20
+ .describe('Project directory. Relative paths resolve against the server root; defaults to it.');
21
+ /** Mirrors the Diagnostic interface in @impetik/xeer-spec, loosely so new fields pass through. */
22
+ const diagnosticSchema = z.object({
23
+ code: z.string(),
24
+ severity: z.string(),
25
+ message: z.string(),
26
+ file: z.string().optional(),
27
+ span: z.object({
28
+ line: z.number(),
29
+ column: z.number(),
30
+ length: z.number().optional(),
31
+ }).loose().optional(),
32
+ hint: z.string().optional(),
33
+ }).loose();
34
+ const envelopeOutput = {
35
+ argv: z.array(z.string()).describe('The exact xeer argv that produced this envelope.'),
36
+ exitCode: z.number().nullable(),
37
+ protocol: z.literal('xeer.command.v0'),
38
+ command: z.string(),
39
+ ok: z.boolean(),
40
+ diagnostics: z.array(diagnosticSchema),
41
+ result: z.unknown().optional(),
42
+ stderr: z.string().optional(),
43
+ };
44
+ function structured(payload) {
45
+ return {
46
+ // The text block is the same JSON, because a client that ignores
47
+ // structuredContent must still see the whole envelope.
48
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
49
+ structuredContent: payload,
50
+ ...(payload['ok'] === false ? { isError: false } : {}),
51
+ };
52
+ }
53
+ function failure(message) {
54
+ return { content: [{ type: 'text', text: message }], isError: true };
55
+ }
56
+ /** Runs a CLI command and shapes the result identically for every tool. */
57
+ async function commandTool(args, directory) {
58
+ const run = await runXeerCommand(args, { cwd: directory });
59
+ return structured({
60
+ argv: run.argv,
61
+ exitCode: run.exitCode,
62
+ ...run.envelope,
63
+ ...(run.stderr ? { stderr: run.stderr } : {}),
64
+ });
65
+ }
66
+ async function withDirectory(directory, body) {
67
+ try {
68
+ return await body(await resolveDirectory(directory));
69
+ }
70
+ catch (error) {
71
+ if (error instanceof XeerCliError)
72
+ return failure(error.message);
73
+ throw error;
74
+ }
75
+ }
76
+ const CHECK_DESCRIPTION = [
77
+ 'Validate a Xeer project and return its diagnostics as JSON. This is the repair loop:',
78
+ 'read diagnostics, apply the smallest edit each one names, run it again, converge on ok: true.',
79
+ 'Runs the manifest stage and, if that passes, the module-graph/zone/operations/type analysis.',
80
+ 'Envelope: { protocol: "xeer.command.v0", command: "check", ok, diagnostics: Diagnostic[],',
81
+ 'result?: { manifest } }. Diagnostic codes are stable — look one up with xeer_diagnostics.',
82
+ ].join(' ');
83
+ const BUILD_DESCRIPTION = [
84
+ 'Build the content-addressed artifact. Runs check first, so build diagnostics are a superset',
85
+ 'of check diagnostics plus bundling, budget, and asset codes (XE14xx, XE15xx).',
86
+ 'On success result is { artifactId, outputDirectory, modules, assets, operations }.',
87
+ 'Run xeer_test before this to prove the application behaves, not just that it compiles.',
88
+ ].join(' ');
89
+ export function createXeerMcpServer() {
90
+ const server = new McpServer({ name: 'xeer', version }, {
91
+ instructions: [
92
+ 'Xeer is a constrained full-stack platform whose diagnostics are its agent surface.',
93
+ `Project root for this server: ${projectRoot()}.`,
94
+ 'Loop: xeer_new to scaffold, xeer_check after every edit, xeer_test to prove behaviour,',
95
+ 'xeer_build before deploy. A green check means it compiles; only a green test means it works.',
96
+ 'Command tools return the CLI\'s xeer.command.v0 envelope verbatim; dispatch on diagnostics[].code',
97
+ 'and use xeer_diagnostics to learn what a code means and which edit closes it.',
98
+ 'Use xeer_dev_start/status/stop only when you need a running preview and inspector;',
99
+ 'a check-edit-check loop plus xeer_test is enough to build and repair a project.',
100
+ ].join(' '),
101
+ });
102
+ const devSessions = new DevSessionRegistry();
103
+ server.registerTool('xeer_check', {
104
+ title: 'Check a Xeer project',
105
+ description: CHECK_DESCRIPTION,
106
+ inputSchema: { directory: directoryArgument },
107
+ outputSchema: envelopeOutput,
108
+ annotations: { readOnlyHint: true, openWorldHint: false },
109
+ }, async ({ directory }) => withDirectory(directory, (resolved) => commandTool(['check', resolved], resolved)));
110
+ server.registerTool('xeer_build', {
111
+ title: 'Build a Xeer project',
112
+ description: BUILD_DESCRIPTION,
113
+ inputSchema: { directory: directoryArgument },
114
+ outputSchema: envelopeOutput,
115
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
116
+ }, async ({ directory }) => withDirectory(directory, (resolved) => commandTool(['build', resolved], resolved)));
117
+ server.registerTool('xeer_test', {
118
+ title: 'Run a Xeer project\'s tests',
119
+ description: [
120
+ 'Run the application\'s own tests: build, type-check tests/**/*.test.ts against the generated',
121
+ 'contract, boot the verified artifact in workerd with fresh isolated state, run every test, tear',
122
+ 'down. This is the convergence check — a green check proves the project compiles, a green test',
123
+ 'proves it behaves. Returns { ok, total, passed, failed, cases, diagnostics, events }.',
124
+ 'Each failed case carries a Diagnostic-shaped failure with matcher, expected, actual, and the',
125
+ 'project-relative test location: XE1904 assertion, XE1905 threw or unexpectedly refused call,',
126
+ 'XE1906 timeout. Compiler diagnostics abort the run before any test boots.',
127
+ 'Tests call as(\'alice\'|\'bob\'|\'guest\'), so ownership and authorization are directly testable.',
128
+ ].join(' '),
129
+ inputSchema: {
130
+ directory: directoryArgument,
131
+ timeoutMilliseconds: z.number().int().min(1_000).max(1_800_000).optional(),
132
+ },
133
+ outputSchema: {
134
+ argv: z.array(z.string()),
135
+ exitCode: z.number().nullable(),
136
+ ok: z.boolean(),
137
+ reason: z.string().optional(),
138
+ total: z.number(),
139
+ passed: z.number(),
140
+ failed: z.number(),
141
+ durationMs: z.number().optional(),
142
+ files: z.array(z.string()),
143
+ cases: z.array(z.object({
144
+ file: z.string(),
145
+ name: z.string(),
146
+ status: z.enum(['passed', 'failed']),
147
+ durationMs: z.number().optional(),
148
+ failure: diagnosticSchema.optional(),
149
+ }).loose()),
150
+ diagnostics: z.array(diagnosticSchema),
151
+ events: z.array(z.object({
152
+ protocol: z.literal('xeer.dev.v0'),
153
+ seq: z.number(),
154
+ time: z.string(),
155
+ type: z.string(),
156
+ data: z.unknown(),
157
+ }).loose()),
158
+ stderr: z.string().optional(),
159
+ },
160
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
161
+ }, async ({ directory, timeoutMilliseconds }) => withDirectory(directory, async (resolved) => {
162
+ const run = await runXeerTests({
163
+ directory: resolved,
164
+ ...(timeoutMilliseconds === undefined ? {} : { timeoutMilliseconds }),
165
+ });
166
+ return structured(run);
167
+ }));
168
+ server.registerTool('xeer_new', {
169
+ title: 'Scaffold a Xeer project',
170
+ description: 'Create a new Xeer project in an empty or non-existent directory. '
171
+ + 'The scaffold checks and builds clean, so use it as the starting point rather than writing '
172
+ + 'a manifest by hand. result is { directory, name, files }.',
173
+ inputSchema: {
174
+ directory: z.string().describe('Target directory, relative to the server root. Must be empty or absent.'),
175
+ },
176
+ outputSchema: envelopeOutput,
177
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
178
+ }, async ({ directory }) => withDirectory(directory, (resolved) => commandTool(['new', resolved], projectRoot())));
179
+ server.registerTool('xeer_doctor', {
180
+ title: 'Diagnose the Xeer toolchain',
181
+ description: 'Check the local toolchain and generated-file state. Use it when a command fails for '
182
+ + 'a reason no project edit explains. result is { checks, summary }; failures also appear as XE4001 '
183
+ + 'diagnostics.',
184
+ inputSchema: { directory: directoryArgument },
185
+ outputSchema: envelopeOutput,
186
+ annotations: { readOnlyHint: true, openWorldHint: false },
187
+ }, async ({ directory }) => withDirectory(directory, (resolved) => commandTool(['doctor', resolved], resolved)));
188
+ server.registerTool('xeer_deploy', {
189
+ title: 'Deploy a Xeer project',
190
+ description: 'Build, verify, and upload the artifact to the control plane. Requires a builder '
191
+ + 'credential: XE5002 means a human must run `xeer auth login` first. result is '
192
+ + '{ application, url, ... }.',
193
+ inputSchema: {
194
+ directory: directoryArgument,
195
+ controlUrl: z.string().optional().describe('Control-plane origin override, e.g. https://control.example.com.'),
196
+ },
197
+ outputSchema: envelopeOutput,
198
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
199
+ }, async ({ directory, controlUrl }) => withDirectory(directory, (resolved) => commandTool(['deploy', resolved, ...(controlUrl ? ['--control-url', controlUrl] : [])], resolved)));
200
+ server.registerTool('xeer_auth_status', {
201
+ title: 'Report builder sign-in status',
202
+ description: 'Report whether a builder credential is available for deployment. Sign-in itself is '
203
+ + 'interactive and cannot be completed by an agent; XE5002 means ask the human to run '
204
+ + '`xeer auth login`.',
205
+ inputSchema: {
206
+ controlUrl: z.string().optional().describe('Control-plane origin override.'),
207
+ },
208
+ outputSchema: envelopeOutput,
209
+ annotations: { readOnlyHint: true, openWorldHint: true },
210
+ }, async ({ controlUrl }) => commandTool(['auth', 'status', ...(controlUrl ? ['--control-url', controlUrl] : [])], projectRoot()));
211
+ server.registerTool('xeer_inspect', {
212
+ title: 'Inspect a running preview',
213
+ description: 'Read the inspector API of a running dev or preview server: normalized manifest '
214
+ + '(manifest), per-table record counts (state), the bounded structured log ring (logs), or '
215
+ + 'every stored record with the schema that describes it (export). Use the URLs from '
216
+ + 'xeer_dev_status.preview.',
217
+ inputSchema: {
218
+ previewUrl: z.string().describe('Preview URL from preview.ready, e.g. http://127.0.0.1:43127/.'),
219
+ view: z.enum(['manifest', 'state', 'logs', 'export']).default('manifest'),
220
+ after: z.string().optional().describe('Log cursor, for view: "logs".'),
221
+ },
222
+ outputSchema: envelopeOutput,
223
+ annotations: { readOnlyHint: true, openWorldHint: true },
224
+ }, async ({ previewUrl, view, after }) => {
225
+ const command = view === 'manifest' ? 'inspect' : view;
226
+ return commandTool([command, previewUrl, ...(view === 'logs' && after ? ['--after', after] : [])], projectRoot());
227
+ });
228
+ const sessionOutput = {
229
+ sessionId: z.string(),
230
+ directory: z.string(),
231
+ status: z.enum(['starting', 'ready', 'compile_failed', 'stopped', 'crashed']),
232
+ running: z.boolean(),
233
+ cursor: z.number(),
234
+ generation: z.number().optional(),
235
+ artifactId: z.string().optional(),
236
+ preview: z.object({
237
+ url: z.string(),
238
+ healthUrl: z.string(),
239
+ inspectorUrl: z.string(),
240
+ debugUrl: z.string(),
241
+ logsUrl: z.string(),
242
+ }).loose().optional(),
243
+ openDiagnostics: z.array(diagnosticSchema),
244
+ exit: z.object({ code: z.number().optional(), reason: z.string().optional() }).loose().optional(),
245
+ droppedEvents: z.number(),
246
+ stderr: z.string().optional(),
247
+ events: z.array(z.object({
248
+ protocol: z.literal('xeer.dev.v0'),
249
+ seq: z.number(),
250
+ time: z.string(),
251
+ type: z.string(),
252
+ data: z.unknown(),
253
+ }).loose()),
254
+ };
255
+ server.registerTool('xeer_dev_start', {
256
+ title: 'Start a dev session',
257
+ description: [
258
+ 'Start `xeer dev --json` and return the xeer.dev.v0 events it emitted up to the point it settled.',
259
+ 'Returns when preview.ready arrives (status "ready"), or when the first compile fails',
260
+ '(status "compile_failed", openDiagnostics populated). Port 0 by default, so the real URL is in',
261
+ 'preview.ready. Keep the returned cursor and pass it to xeer_dev_status after each edit.',
262
+ 'You do not need a dev session to repair diagnostics: xeer_check is the same compiler.',
263
+ ].join(' '),
264
+ inputSchema: {
265
+ directory: directoryArgument,
266
+ host: z.string().optional().describe('Defaults to 127.0.0.1.'),
267
+ port: z.number().int().min(0).max(65535).optional().describe('Defaults to 0 (OS-assigned).'),
268
+ timeoutMilliseconds: z.number().int().min(1_000).max(600_000).optional(),
269
+ },
270
+ outputSchema: sessionOutput,
271
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
272
+ }, async ({ directory, host, port, timeoutMilliseconds }) => withDirectory(directory, async (resolved) => {
273
+ let session;
274
+ try {
275
+ session = devSessions.start({
276
+ directory: resolved,
277
+ ...(host ? { host } : {}),
278
+ ...(port === undefined ? {} : { port }),
279
+ });
280
+ }
281
+ catch (error) {
282
+ return failure(error instanceof Error ? error.message : String(error));
283
+ }
284
+ await session.waitForStart(timeoutMilliseconds ?? 180_000);
285
+ return structured(session.read(0));
286
+ }));
287
+ server.registerTool('xeer_dev_status', {
288
+ title: 'Read new dev-session events',
289
+ description: [
290
+ 'Read the xeer.dev.v0 events emitted since `cursor`, then return the session summary.',
291
+ 'After editing a file, call this with waitMilliseconds set: the CLI coalesces changes behind a',
292
+ '75 ms quiet window, rebuilds, and emits compile.start, any compile.diagnostic, and compile.ready',
293
+ 'when the rebuild is accepted. openDiagnostics is the outcome of the latest attempt, so an empty',
294
+ 'openDiagnostics with a higher generation means the edit was accepted.',
295
+ ].join(' '),
296
+ inputSchema: {
297
+ sessionId: z.string().optional().describe('Optional when only one session exists.'),
298
+ cursor: z.number().int().min(0).default(0).describe('Last seq you have already read.'),
299
+ waitMilliseconds: z.number().int().min(0).max(600_000).default(0)
300
+ .describe('Wait up to this long for new events to arrive and settle.'),
301
+ limit: z.number().int().min(1).max(1_000).default(200),
302
+ },
303
+ outputSchema: sessionOutput,
304
+ annotations: { readOnlyHint: true, openWorldHint: false },
305
+ }, async ({ sessionId, cursor, waitMilliseconds, limit }) => {
306
+ let session;
307
+ try {
308
+ session = devSessions.get(sessionId);
309
+ }
310
+ catch (error) {
311
+ return failure(error instanceof Error ? error.message : String(error));
312
+ }
313
+ await session.waitForActivity(cursor, waitMilliseconds);
314
+ return structured(session.read(cursor, limit));
315
+ });
316
+ server.registerTool('xeer_dev_stop', {
317
+ title: 'Stop a dev session',
318
+ description: 'Stop a dev session over the documented xeer.dev.control.v0 IPC shutdown, releasing '
319
+ + 'the local state lease. Always stop sessions you started: the lease is per project and mode, '
320
+ + 'so a leaked session blocks the next dev run with XE1812.',
321
+ inputSchema: {
322
+ sessionId: z.string().optional().describe('Optional when only one session exists.'),
323
+ cursor: z.number().int().min(0).default(0),
324
+ },
325
+ outputSchema: sessionOutput,
326
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
327
+ }, async ({ sessionId, cursor }) => {
328
+ let session;
329
+ try {
330
+ session = devSessions.get(sessionId);
331
+ }
332
+ catch (error) {
333
+ return failure(error instanceof Error ? error.message : String(error));
334
+ }
335
+ await session.stop();
336
+ return structured(session.read(cursor));
337
+ });
338
+ server.registerTool('xeer_diagnostics', {
339
+ title: 'Look up Xeer diagnostic codes',
340
+ description: 'Explain a diagnostic code: what the platform observed and which edit closes it. '
341
+ + 'Omit `code` for the whole generated reference. This is the same catalogue the compiler emits '
342
+ + 'from, so it cannot drift from the codes you receive.',
343
+ inputSchema: {
344
+ code: z.string().optional().describe('An XE#### code, e.g. XE1202.'),
345
+ },
346
+ outputSchema: {
347
+ code: z.string().optional(),
348
+ family: z.string().optional(),
349
+ means: z.string().optional(),
350
+ repair: z.string().optional(),
351
+ surfaces: z.array(z.string()).optional(),
352
+ codes: z.array(z.string()).optional(),
353
+ reference: z.string().optional(),
354
+ },
355
+ annotations: { readOnlyHint: true, openWorldHint: false },
356
+ }, async ({ code }) => {
357
+ if (!code) {
358
+ return structured({ codes: [...DIAGNOSTIC_CODES], reference: renderDiagnosticsReference() });
359
+ }
360
+ const normalized = code.trim().toUpperCase();
361
+ const definition = diagnosticDefinition(normalized);
362
+ if (!definition) {
363
+ const known = Object.keys(DIAGNOSTICS).sort().join(', ');
364
+ return failure(`Unknown diagnostic code: ${normalized}. Known codes: ${known}`);
365
+ }
366
+ const family = DIAGNOSTIC_FAMILIES.find((candidate) => candidate.prefix === definition.prefix);
367
+ return structured({
368
+ code: definition.code,
369
+ family: family ? `${family.title}: ${family.summary}` : definition.prefix,
370
+ means: definition.means,
371
+ repair: definition.repair,
372
+ surfaces: [...definition.surfaces],
373
+ });
374
+ });
375
+ return { server, devSessions };
376
+ }
@@ -0,0 +1,57 @@
1
+ import type { DevEvent, Diagnostic } from '@impetik/xeer-spec';
2
+ /**
3
+ * `xeer test --json` is the one command that streams instead of printing an
4
+ * envelope: it reuses the `xeer.dev.v0` JSONL protocol so a consumer sees the
5
+ * build, then each case as it runs (docs/specs/test-protocol-v0.md).
6
+ *
7
+ * It is also short-lived and terminal, unlike `xeer dev`, so it needs no session
8
+ * handle. This collects the stream, folds it into the summary an agent acts on —
9
+ * which cases failed, and the Diagnostic-shaped failure for each — and returns
10
+ * the raw events alongside it so nothing is lost to the summary.
11
+ */
12
+ /** A test failure is a Diagnostic plus the assertion detail. */
13
+ export interface TestFailure extends Diagnostic {
14
+ kind?: 'assertion' | 'error' | 'timeout' | string;
15
+ matcher?: string;
16
+ negated?: boolean;
17
+ expected?: string;
18
+ actual?: string;
19
+ operation?: {
20
+ persona?: string;
21
+ kind?: string;
22
+ name?: string;
23
+ status?: number;
24
+ errorCode?: string;
25
+ errorId?: string;
26
+ };
27
+ }
28
+ export interface TestCaseResult {
29
+ file: string;
30
+ name: string;
31
+ status: 'passed' | 'failed';
32
+ durationMs?: number;
33
+ failure?: TestFailure;
34
+ }
35
+ export interface TestRunResult {
36
+ argv: string[];
37
+ exitCode: number | null;
38
+ /** 0 every test passed, 1 a diagnostic or failed test, 2 the runtime could not start. */
39
+ ok: boolean;
40
+ /** `complete`, `tests_failed`, `build_failed`, `no_tests`, `test_type_errors`, `test_compile_failed`, `runtime_failed`. */
41
+ reason?: string;
42
+ total: number;
43
+ passed: number;
44
+ failed: number;
45
+ durationMs?: number;
46
+ files: string[];
47
+ cases: TestCaseResult[];
48
+ /** Compiler diagnostics, which abort the run before any test boots. */
49
+ diagnostics: Diagnostic[];
50
+ events: DevEvent[];
51
+ stderr?: string;
52
+ }
53
+ export interface RunTestsOptions {
54
+ directory: string;
55
+ timeoutMilliseconds?: number;
56
+ }
57
+ export declare function runXeerTests(options: RunTestsOptions): Promise<TestRunResult>;
@@ -0,0 +1,118 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveXeerCli } from './xeer-cli.js';
3
+ const DEFAULT_TIMEOUT_MILLISECONDS = 600_000;
4
+ const MAX_EVENTS = 2_000;
5
+ export async function runXeerTests(options) {
6
+ const cliPath = resolveXeerCli();
7
+ const argv = ['test', options.directory, '--json'];
8
+ const timeout = options.timeoutMilliseconds ?? DEFAULT_TIMEOUT_MILLISECONDS;
9
+ return new Promise((done) => {
10
+ const child = spawn(process.execPath, [cliPath, ...argv], {
11
+ cwd: options.directory,
12
+ stdio: ['ignore', 'pipe', 'pipe'],
13
+ env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
14
+ });
15
+ const events = [];
16
+ const diagnostics = [];
17
+ const cases = [];
18
+ let files = [];
19
+ let summary = null;
20
+ let reason;
21
+ let stdout = '';
22
+ let stderr = '';
23
+ let timedOut = false;
24
+ const timer = setTimeout(() => {
25
+ timedOut = true;
26
+ child.kill('SIGKILL');
27
+ }, timeout);
28
+ const ingest = (line) => {
29
+ let event;
30
+ try {
31
+ const parsed = JSON.parse(line);
32
+ if (parsed?.protocol !== 'xeer.dev.v0' || typeof parsed.type !== 'string')
33
+ return;
34
+ event = parsed;
35
+ }
36
+ catch {
37
+ return;
38
+ }
39
+ if (events.length < MAX_EVENTS)
40
+ events.push(event);
41
+ switch (event.type) {
42
+ case 'compile.diagnostic':
43
+ diagnostics.push(event.data);
44
+ break;
45
+ case 'test.run.start':
46
+ files = event.data.files ?? [];
47
+ break;
48
+ case 'test.case.pass': {
49
+ const data = event.data;
50
+ cases.push({ file: data.file, name: data.name, status: 'passed',
51
+ ...(data.durationMs === undefined ? {} : { durationMs: data.durationMs }) });
52
+ break;
53
+ }
54
+ case 'test.case.fail': {
55
+ const data = event.data;
56
+ cases.push({ file: data.file, name: data.name, status: 'failed',
57
+ ...(data.durationMs === undefined ? {} : { durationMs: data.durationMs }),
58
+ ...(data.failure ? { failure: data.failure } : {}) });
59
+ break;
60
+ }
61
+ case 'test.run.complete':
62
+ summary = event.data;
63
+ break;
64
+ case 'process.exit':
65
+ reason = event.data.reason;
66
+ break;
67
+ default:
68
+ break;
69
+ }
70
+ };
71
+ child.stdout?.setEncoding('utf8');
72
+ child.stderr?.setEncoding('utf8');
73
+ child.stdout?.on('data', (chunk) => {
74
+ stdout += chunk;
75
+ const lines = stdout.split('\n');
76
+ stdout = lines.pop() ?? '';
77
+ for (const line of lines)
78
+ if (line.trim())
79
+ ingest(line.trim());
80
+ });
81
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
82
+ const settle = (exitCode, failure) => {
83
+ clearTimeout(timer);
84
+ if (stdout.trim())
85
+ ingest(stdout.trim());
86
+ const failed = summary?.failed ?? cases.filter((entry) => entry.status === 'failed').length;
87
+ const passed = summary?.passed ?? cases.filter((entry) => entry.status === 'passed').length;
88
+ // A run that failed without emitting either a diagnostic or a summary never
89
+ // reached the runner. Report it as XE0000 rather than as zero tests passing:
90
+ // "no failures" and "nothing ran" must never look alike to an agent.
91
+ if (failure || (exitCode !== 0 && !summary && !diagnostics.length)) {
92
+ diagnostics.push({
93
+ code: 'XE0000',
94
+ severity: 'error',
95
+ message: failure
96
+ ?? (stderr.trim() || `xeer ${argv.join(' ')} exited with ${exitCode} and emitted no events.`),
97
+ });
98
+ }
99
+ done({
100
+ argv,
101
+ exitCode,
102
+ ok: exitCode === 0 && !failure,
103
+ ...(reason ? { reason } : {}),
104
+ total: summary?.total ?? cases.length,
105
+ passed,
106
+ failed,
107
+ ...(summary?.durationMs === undefined ? {} : { durationMs: summary.durationMs }),
108
+ files,
109
+ cases,
110
+ diagnostics,
111
+ events,
112
+ ...(stderr.trim() ? { stderr: stderr.trim() } : {}),
113
+ });
114
+ };
115
+ child.on('error', (error) => settle(null, `Could not run the xeer CLI: ${error.message}`));
116
+ child.on('close', (code) => settle(code, timedOut ? `xeer ${argv.join(' ')} exceeded ${timeout} ms and was killed.` : undefined));
117
+ });
118
+ }
@@ -0,0 +1,51 @@
1
+ import type { Diagnostic } from '@impetik/xeer-spec';
2
+ /**
3
+ * Every tool in this server shells out to the `xeer` CLI with `--json` and
4
+ * forwards the envelope it printed, unchanged.
5
+ *
6
+ * That is deliberate. The CLI's `xeer.command.v0` envelope and its diagnostics
7
+ * are the versioned contract (docs/specs/dev-protocol-v0.md); re-implementing
8
+ * the commands against the compiler API would create a second surface that can
9
+ * drift from the one humans see in a terminal. An agent driving this server and
10
+ * a human running the CLI observe the same bytes.
11
+ */
12
+ /** The envelope `xeer <command> --json` writes to stdout. */
13
+ export interface CommandEnvelope {
14
+ protocol: 'xeer.command.v0';
15
+ command: string;
16
+ ok: boolean;
17
+ diagnostics: Diagnostic[];
18
+ result?: unknown;
19
+ }
20
+ export interface CommandRun {
21
+ /** Argv after the CLI path, so a human can reproduce the call. */
22
+ argv: string[];
23
+ exitCode: number | null;
24
+ envelope: CommandEnvelope;
25
+ /** Present only when the CLI wrote to stderr, which JSON mode reserves for fatal failures. */
26
+ stderr?: string;
27
+ }
28
+ export declare class XeerCliError extends Error {
29
+ }
30
+ /**
31
+ * The CLI is resolved through this package's own dependency graph, so the server
32
+ * cannot silently drive a different xeer than the one it was installed with.
33
+ * XEER_CLI overrides it for a workspace checkout or a pinned build.
34
+ */
35
+ export declare function resolveXeerCli(): string;
36
+ /**
37
+ * Directory arguments are confined to a root, which defaults to the directory
38
+ * the server was started in. An MCP server is reachable by anything that can
39
+ * talk to the harness, so "which project" is an authorization question, not a
40
+ * convenience one. Set XEER_MCP_ROOT to widen it deliberately.
41
+ */
42
+ export declare function projectRoot(): string;
43
+ /** Resolves a tool's `directory` argument against the confinement root. */
44
+ export declare function resolveDirectory(directory: string | undefined): Promise<string>;
45
+ export interface RunOptions {
46
+ /** Working directory for the child. Defaults to the resolved project directory. */
47
+ cwd?: string;
48
+ timeoutMilliseconds?: number;
49
+ }
50
+ /** Runs `xeer <args> --json` and returns the envelope it printed. */
51
+ export declare function runXeerCommand(args: string[], options?: RunOptions): Promise<CommandRun>;
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
5
+ export class XeerCliError extends Error {
6
+ }
7
+ const COMMAND_PROTOCOL = 'xeer.command.v0';
8
+ const DEFAULT_TIMEOUT_MILLISECONDS = 300_000;
9
+ /**
10
+ * The CLI is resolved through this package's own dependency graph, so the server
11
+ * cannot silently drive a different xeer than the one it was installed with.
12
+ * XEER_CLI overrides it for a workspace checkout or a pinned build.
13
+ */
14
+ export function resolveXeerCli() {
15
+ const override = process.env.XEER_CLI;
16
+ if (override)
17
+ return resolve(override);
18
+ const require = createRequire(import.meta.url);
19
+ try {
20
+ return resolve(dirname(require.resolve('@impetik/xeer')), 'cli.js');
21
+ }
22
+ catch (error) {
23
+ throw new XeerCliError('Could not resolve the xeer CLI. Install @impetik/xeer alongside this server, '
24
+ + `or set XEER_CLI to its dist/cli.js: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+ /**
28
+ * Directory arguments are confined to a root, which defaults to the directory
29
+ * the server was started in. An MCP server is reachable by anything that can
30
+ * talk to the harness, so "which project" is an authorization question, not a
31
+ * convenience one. Set XEER_MCP_ROOT to widen it deliberately.
32
+ */
33
+ export function projectRoot() {
34
+ return resolve(process.env.XEER_MCP_ROOT ?? process.cwd());
35
+ }
36
+ function contains(root, candidate) {
37
+ const rel = relative(root, candidate);
38
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
39
+ }
40
+ /** Resolves a tool's `directory` argument against the confinement root. */
41
+ export async function resolveDirectory(directory) {
42
+ const root = projectRoot();
43
+ const requested = resolve(root, directory ?? '.');
44
+ const rootReal = await realpath(root).catch(() => root);
45
+ const requestedReal = await realpath(requested).catch(() => requested);
46
+ if (!contains(root, requested) || !contains(rootReal, requestedReal)) {
47
+ throw new XeerCliError(`directory must stay inside ${root}. Received ${requested}. `
48
+ + 'Set XEER_MCP_ROOT when the server must reach another tree.');
49
+ }
50
+ return requested;
51
+ }
52
+ function isEnvelope(value) {
53
+ if (typeof value !== 'object' || value === null)
54
+ return false;
55
+ const candidate = value;
56
+ return candidate.protocol === COMMAND_PROTOCOL
57
+ && typeof candidate.command === 'string'
58
+ && typeof candidate.ok === 'boolean'
59
+ && Array.isArray(candidate.diagnostics);
60
+ }
61
+ /**
62
+ * A CLI crash has no envelope to forward, so one is synthesized with XE0000 —
63
+ * the same code the CLI itself writes to stderr for an internal failure. A tool
64
+ * result is therefore always an envelope, and an agent never has to branch on
65
+ * "did the transport work".
66
+ */
67
+ function crashEnvelope(command, message) {
68
+ return {
69
+ protocol: COMMAND_PROTOCOL,
70
+ command,
71
+ ok: false,
72
+ diagnostics: [{ code: 'XE0000', severity: 'error', message }],
73
+ };
74
+ }
75
+ /** Runs `xeer <args> --json` and returns the envelope it printed. */
76
+ export async function runXeerCommand(args, options = {}) {
77
+ const cliPath = resolveXeerCli();
78
+ const argv = args.includes('--json') ? [...args] : [...args, '--json'];
79
+ const command = args[0] ?? 'unknown';
80
+ const timeout = options.timeoutMilliseconds ?? DEFAULT_TIMEOUT_MILLISECONDS;
81
+ return new Promise((done) => {
82
+ const child = spawn(process.execPath, [cliPath, ...argv], {
83
+ ...(options.cwd ? { cwd: options.cwd } : {}),
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
86
+ });
87
+ let stdout = '';
88
+ let stderr = '';
89
+ let timedOut = false;
90
+ const timer = setTimeout(() => {
91
+ timedOut = true;
92
+ child.kill('SIGKILL');
93
+ }, timeout);
94
+ child.stdout?.setEncoding('utf8');
95
+ child.stderr?.setEncoding('utf8');
96
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
97
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
98
+ const settle = (exitCode, failure) => {
99
+ clearTimeout(timer);
100
+ // JSON mode prints exactly one envelope; take the last complete line so a
101
+ // stray warning ahead of it cannot break parsing.
102
+ const line = stdout.split('\n').map((entry) => entry.trim()).filter(Boolean).at(-1);
103
+ let envelope = null;
104
+ if (line) {
105
+ try {
106
+ const parsed = JSON.parse(line);
107
+ if (isEnvelope(parsed))
108
+ envelope = parsed;
109
+ }
110
+ catch { /* Fall through to the synthesized envelope. */ }
111
+ }
112
+ done({
113
+ argv,
114
+ exitCode,
115
+ envelope: envelope ?? crashEnvelope(command, failure
116
+ ?? (stderr.trim() || `xeer ${argv.join(' ')} exited with ${exitCode} and printed no envelope.`)),
117
+ ...(stderr.trim() ? { stderr: stderr.trim() } : {}),
118
+ });
119
+ };
120
+ child.on('error', (error) => settle(null, `Could not run the xeer CLI: ${error.message}`));
121
+ child.on('close', (code) => settle(code, timedOut ? `xeer ${argv.join(' ')} exceeded ${timeout} ms and was killed.` : undefined));
122
+ });
123
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@impetik/xeer-mcp",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "Model Context Protocol server for Xeer: the scaffold, check, dev, build, and deploy loop as agent tools.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/impetik/xeer.git",
10
+ "directory": "packages/mcp"
11
+ },
12
+ "homepage": "https://github.com/impetik/xeer#readme",
13
+ "bugs": "https://github.com/impetik/xeer/issues",
14
+ "keywords": [
15
+ "xeer",
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "agent",
19
+ "diagnostics"
20
+ ],
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE",
25
+ "!dist/**/*.map",
26
+ "!dist/**/*.test.*"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.12.0"
33
+ },
34
+ "bin": {
35
+ "xeer-mcp": "./dist/main.js"
36
+ },
37
+ "exports": {
38
+ ".": {
39
+ "types": "./dist/index.d.ts",
40
+ "default": "./dist/index.js"
41
+ }
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.29.0",
45
+ "zod": "^4.0.10",
46
+ "@impetik/xeer": "0.2.1",
47
+ "@impetik/xeer-spec": "0.2.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^24.1.0"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc -p tsconfig.json",
54
+ "check": "tsc -p tsconfig.json --noEmit",
55
+ "test": "vitest run --testTimeout 120000",
56
+ "test:smoke": "vitest run --config vitest.smoke.config.ts",
57
+ "start": "node dist/main.js"
58
+ }
59
+ }