@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 +21 -0
- package/README.md +93 -0
- package/dist/dev-session.d.ts +92 -0
- package/dist/dev-session.js +285 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +27 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +376 -0
- package/dist/test-run.d.ts +57 -0
- package/dist/test-run.js +118 -0
- package/dist/xeer-cli.d.ts +51 -0
- package/dist/xeer-cli.js +123 -0
- package/package.json +59 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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>;
|
package/dist/test-run.js
ADDED
|
@@ -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>;
|
package/dist/xeer-cli.js
ADDED
|
@@ -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
|
+
}
|