@rse/ase 0.0.14 → 0.0.16

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/dst/ase-config.js CHANGED
@@ -324,16 +324,18 @@ export class Config {
324
324
  if (sc.kind === "default") {
325
325
  const doc = new Document();
326
326
  doc.contents = doc.createNode({});
327
- const preset = projectClassificationPresets.default;
328
- for (const [k, val] of Object.entries(preset)) {
329
- const segments = k.split(".");
330
- for (let j = 1; j < segments.length; j++) {
331
- const prefix = segments.slice(0, j);
332
- const node = doc.getIn(prefix, true);
333
- if (node === undefined)
334
- doc.setIn(prefix, doc.createNode({}));
327
+ if (this.name === "config") {
328
+ const preset = projectClassificationPresets.default;
329
+ for (const [k, val] of Object.entries(preset)) {
330
+ const segments = k.split(".");
331
+ for (let j = 1; j < segments.length; j++) {
332
+ const prefix = segments.slice(0, j);
333
+ const node = doc.getIn(prefix, true);
334
+ if (node === undefined)
335
+ doc.setIn(prefix, doc.createNode({}));
336
+ }
337
+ doc.setIn(segments, doc.createNode(val));
335
338
  }
336
- doc.setIn(segments, doc.createNode(val));
337
339
  }
338
340
  docs.push({ scope: sc, filename: "", doc });
339
341
  continue;
@@ -1,35 +1,158 @@
1
1
  /*
2
2
  ** Agentic Software Engineering (ASE)
3
- ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>, Matthias Brusdeylins <matthias@brusdeylins.info>
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
5
  */
6
6
  import fs from "node:fs";
7
- import tty from "node:tty";
7
+ import { InvalidArgumentError } from "commander";
8
8
  import { renderMermaidASCII } from "beautiful-mermaid";
9
- /* detect terminal color capability via /dev/tty */
10
- /* (stdout is piped to capture diagram output, so query the controlling terminal directly) */
9
+ /* custom argument parser for Commander: non-negative integer */
10
+ const parseInteger = (name) => (value) => {
11
+ const n = Number.parseInt(value, 10);
12
+ if (!Number.isFinite(n) || n < 0)
13
+ throw new InvalidArgumentError(`${name} must be a non-negative integer`);
14
+ return n;
15
+ };
16
+ /* custom argument parser for Commander: color mode */
17
+ const parseColorMode = (name) => (value) => {
18
+ if (value !== "none" && value !== "ansi16" && value !== "ansi256")
19
+ throw new InvalidArgumentError(`${name} must be "none", "ansi16", or "ansi256"`);
20
+ return value;
21
+ };
22
+ /* detect terminal column width */
23
+ const detectTermWidth = () => {
24
+ let width = 0;
25
+ /* attempt 1: query environment variable */
26
+ if (process.env.ASE_TERM_WIDTH !== undefined) {
27
+ const cols = Number.parseInt(process.env.ASE_TERM_WIDTH, 10);
28
+ if (Number.isFinite(cols) && cols > 0)
29
+ width = cols;
30
+ }
31
+ /* attempt 2: query stdout */
32
+ if (width === 0 && process.stdout.isTTY) {
33
+ const cols = process.stdout.columns;
34
+ if (typeof cols === "number" && cols > 0)
35
+ width = cols;
36
+ }
37
+ return width;
38
+ };
39
+ /* detect terminal row height */
40
+ const detectTermHeight = () => {
41
+ let height = 0;
42
+ /* attempt 1: query environment variable */
43
+ if (process.env.ASE_TERM_HEIGHT !== undefined) {
44
+ const rows = Number.parseInt(process.env.ASE_TERM_HEIGHT, 10);
45
+ if (Number.isFinite(rows) && rows > 0)
46
+ height = rows;
47
+ }
48
+ /* attempt 2: query stdout */
49
+ if (height === 0 && process.stdout.isTTY) {
50
+ const rows = process.stdout.rows;
51
+ if (typeof rows === "number" && rows > 0)
52
+ height = rows;
53
+ }
54
+ return height;
55
+ };
56
+ /* detect terminal color capability */
11
57
  const detectColorMode = () => {
12
- let fd = -1;
13
- try {
14
- fd = fs.openSync("/dev/tty", "r+");
15
- const stream = new tty.WriteStream(fd);
16
- const depth = stream.getColorDepth();
17
- stream.destroy();
58
+ let mode = "none";
59
+ /* attempt 1: query environment variable (explicitly) */
60
+ if (process.env.ASE_TERM_COLORS !== undefined)
61
+ if (process.env.ASE_TERM_COLORS.match(/^(?:none|ansi16|ansi256)$/) !== null)
62
+ mode = process.env.ASE_TERM_COLORS;
63
+ /* attempt 2: query stdout */
64
+ if (mode === "none" && process.stdout.isTTY) {
65
+ const depth = process.stdout.getColorDepth();
18
66
  if (depth >= 8)
19
- return "ansi256";
20
- if (depth >= 4)
21
- return "ansi16";
22
- return "none";
67
+ mode = "ansi256";
68
+ else if (depth >= 4)
69
+ mode = "ansi16";
23
70
  }
24
- catch {
25
- if (fd >= 0) {
26
- try {
27
- fs.closeSync(fd);
71
+ return mode;
72
+ };
73
+ /* truncate a single rendered line to a maximum visible column,
74
+ preserving ANSI escape sequences (CSI ...m) and appending an ANSI
75
+ reset sequence if any styling was active at the truncation point */
76
+ const truncateAnsiLine = (line, budget) => {
77
+ if (budget <= 0)
78
+ return "";
79
+ let out = "";
80
+ let visible = 0;
81
+ let styled = false;
82
+ let i = 0;
83
+ while (i < line.length) {
84
+ const ch = line[i];
85
+ if (ch === "\x1b" && line[i + 1] === "[") {
86
+ let j = i + 2;
87
+ while (j < line.length && !/[A-Za-z]/.test(line[j]))
88
+ j++;
89
+ if (j < line.length) {
90
+ const seq = line.slice(i, j + 1);
91
+ out += seq;
92
+ if (seq.endsWith("m")) {
93
+ const body = seq.slice(2, -1);
94
+ if (body === "" || body === "0")
95
+ styled = false;
96
+ else
97
+ styled = true;
98
+ }
99
+ i = j + 1;
100
+ continue;
28
101
  }
29
- catch { /* ignore */ }
102
+ i++;
103
+ continue;
30
104
  }
31
- return "none";
105
+ if (visible >= budget)
106
+ break;
107
+ out += ch;
108
+ visible++;
109
+ i++;
32
110
  }
111
+ if (styled)
112
+ out += "\x1b[0m";
113
+ return out;
114
+ };
115
+ /* pure rendering helper: turn a Mermaid source string plus options into
116
+ a rendered Unicode/ASCII diagram string. Throws on render failure. */
117
+ export const renderDiagram = (src, opts) => {
118
+ /* create diagram rendering */
119
+ let out = renderMermaidASCII(src, {
120
+ useAscii: opts.ascii,
121
+ paddingX: opts.nodeMarginX,
122
+ paddingY: opts.nodeMarginY,
123
+ boxBorderPadding: opts.nodePadding,
124
+ colorMode: opts.colorMode,
125
+ theme: opts.colorMode !== "none" ? {
126
+ fg: "#000000",
127
+ border: "#a0a0a0",
128
+ junction: "#a0a0a0",
129
+ arrow: "#404040",
130
+ line: "#707070",
131
+ corner: "#707070"
132
+ } : {
133
+ fg: "#000000",
134
+ border: "#000000",
135
+ junction: "#000000",
136
+ arrow: "#000000",
137
+ line: "#000000",
138
+ corner: "#000000"
139
+ }
140
+ });
141
+ /* optionally clip diagram rendering */
142
+ const termWidth = opts.terminalWidth;
143
+ const termHeight = opts.terminalHeight;
144
+ if (termWidth > 0 || termHeight > 0) {
145
+ const maxWidth = termWidth > 0 ? termWidth - opts.diagramClipX : 0;
146
+ const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
147
+ const trailingNL = out.endsWith("\n");
148
+ let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
149
+ if (maxWidth > 0)
150
+ lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
151
+ if (maxHeight > 0 && lines.length > maxHeight)
152
+ lines = lines.slice(0, maxHeight);
153
+ out = lines.join("\n") + (trailingNL ? "\n" : "");
154
+ }
155
+ return out;
33
156
  };
34
157
  /* read stdin into a single string */
35
158
  const readStdin = async () => {
@@ -48,83 +171,52 @@ export default class DiagramCommand {
48
171
  register(program) {
49
172
  program
50
173
  .command("diagram")
51
- .description("Render Mermaid source (stdin or --input) to aligned Unicode/ASCII diagram")
52
- .option("-a, --ascii", "emit plain ASCII (+-|) instead of Unicode box-drawing", false)
53
- .option("-c, --color-mode <mode>", "force particular color mode to use (\"none\", \"ansi16\", or \"ansi256\")")
174
+ .description("Render Mermaid diagram specification as Unicode/ASCII art")
54
175
  .option("-i, --input <file>", "read Mermaid source from file instead of stdin")
55
- .option("-x, --pad-x <n>", "horizontal spacing between nodes", "3")
56
- .option("-y, --pad-y <n>", "vertical spacing between nodes", "3")
57
- .option("-b, --pad-box <n>", "inner node box spacing", "1")
176
+ .option("-a, --ascii", "emit plain ASCII (+-|) instead of Unicode box-drawing", false)
177
+ .option("-c, --color-mode <mode>", "force color mode (\"none\", \"ansi16\", or \"ansi256\")", parseColorMode("--color-mode"), detectColorMode())
178
+ .option("--node-margin-x <n>", "horizontal margin between nodes of <n> characters", parseInteger("--node-margin-x"), 3)
179
+ .option("--node-margin-y <n>", "vertical margin between nodes of <n> lines", parseInteger("--node-margin-y"), 3)
180
+ .option("--node-padding <n>", "horizontal and vertical inner node padding with <n> characters", parseInteger("--node-padding"), 1)
181
+ .option("--diagram-clip-x <n>", "extra horizontal clipping of diagram to terminal width minus <n> characters", parseInteger("--diagram-clip-x"), 0)
182
+ .option("--diagram-clip-y <n>", "extra vertical clipping of diagram to terminal height minus <n> lines", parseInteger("--diagram-clip-y"), 0)
183
+ .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), detectTermWidth())
184
+ .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), detectTermHeight())
58
185
  .action(async (opts) => {
59
- /* load Mermaid source */
186
+ /* fetch Mermaid diagram specification from stdin */
60
187
  let src;
61
188
  if (opts.input !== undefined)
62
189
  src = fs.readFileSync(opts.input, "utf8");
63
190
  else
64
191
  src = await readStdin();
65
192
  if (src.trim() === "") {
66
- this.log.write("error", "diagram: empty Mermaid source");
67
- process.exit(1);
68
- }
69
- /* parse spacing options */
70
- const paddingX = Number.parseInt(opts.padX ?? "3", 10);
71
- if (!Number.isFinite(paddingX)) {
72
- this.log.write("error", "diagram: --pad-x must be integer");
73
- process.exit(1);
74
- }
75
- const paddingY = Number.parseInt(opts.padY ?? "3", 10);
76
- if (!Number.isFinite(paddingY)) {
77
- this.log.write("error", "diagram: --pad-y must be integer");
78
- process.exit(1);
79
- }
80
- const boxBorderPadding = Number.parseInt(opts.padBox ?? "1", 10);
81
- if (!Number.isFinite(boxBorderPadding)) {
82
- this.log.write("error", "diagram: --pad-box must be integer");
83
- process.exit(1);
84
- }
85
- /* determine color mode (explicit option overrides auto-detection) */
86
- let colorMode;
87
- if (opts.colorMode === "none" || opts.colorMode === "ansi16" || opts.colorMode === "ansi256")
88
- colorMode = opts.colorMode;
89
- else if (opts.colorMode === undefined)
90
- colorMode = detectColorMode();
91
- else {
92
- this.log.write("error", "diagram: --color-mode must be \"none\", \"ansi16\", or \"ansi256\"");
193
+ this.log.write("error", "diagram: empty Mermaid diagram specification");
93
194
  process.exit(1);
94
195
  }
95
- /* render to ASCII */
196
+ /* create diagram rendering */
197
+ let out;
96
198
  try {
97
- const out = renderMermaidASCII(src, {
98
- useAscii: opts.ascii ?? false,
99
- paddingX,
100
- paddingY,
101
- boxBorderPadding,
102
- colorMode,
103
- theme: colorMode !== "none" ? {
104
- fg: "#000000",
105
- border: "#a0a0a0",
106
- junction: "#a0a0a0",
107
- arrow: "#404040",
108
- line: "#707070",
109
- corner: "#707070"
110
- } : {
111
- fg: "#000000",
112
- border: "#000000",
113
- junction: "#000000",
114
- arrow: "#000000",
115
- line: "#000000",
116
- corner: "#000000"
117
- }
199
+ out = renderDiagram(src, {
200
+ ascii: opts.ascii ?? false,
201
+ colorMode: opts.colorMode,
202
+ nodeMarginX: opts.nodeMarginX,
203
+ nodeMarginY: opts.nodeMarginY,
204
+ nodePadding: opts.nodePadding,
205
+ diagramClipX: opts.diagramClipX,
206
+ diagramClipY: opts.diagramClipY,
207
+ terminalWidth: opts.terminalWidth,
208
+ terminalHeight: opts.terminalHeight
118
209
  });
119
- process.stdout.write(out);
120
- if (!out.endsWith("\n"))
121
- process.stdout.write("\n");
122
210
  }
123
211
  catch (err) {
124
212
  const message = err instanceof Error ? err.message : String(err);
125
213
  this.log.write("error", `diagram: render failed: ${message}`);
126
214
  process.exit(1);
127
215
  }
216
+ /* output diagram rendering */
217
+ process.stdout.write(out);
218
+ if (!out.endsWith("\n"))
219
+ process.stdout.write("\n");
128
220
  });
129
221
  }
130
222
  }
package/dst/ase-hook.js CHANGED
@@ -80,6 +80,10 @@ export default class HookCommand {
80
80
  approve = true;
81
81
  reason = "ASE skill invocation auto-approved";
82
82
  }
83
+ else if (/^mcp__plugin_ase_ase__.+/.test(toolName)) {
84
+ approve = true;
85
+ reason = "ASE MCP tool invocation auto-approved";
86
+ }
83
87
  /* emit permission decision (or stay silent to defer to default flow) */
84
88
  if (approve) {
85
89
  process.stdout.write(JSON.stringify({
package/dst/ase-mcp.js ADDED
@@ -0,0 +1,156 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import axios from "axios";
9
+ import * as v from "valibot";
10
+ import { execa } from "execa";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ import { Config, configSchema } from "./ase-config.js";
14
+ const HOST = "127.0.0.1";
15
+ /* schema for ".ase/service.yaml" (same shape as in ase-service.ts) */
16
+ const serviceSchema = v.nullish(v.strictObject({
17
+ port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1024), v.maxValue(65535)))
18
+ }));
19
+ /* distinguish ECONNREFUSED from other Axios transport errors */
20
+ const isConnRefused = (err) => {
21
+ const e = err;
22
+ return e?.code === "ECONNREFUSED" || e?.cause?.code === "ECONNREFUSED";
23
+ };
24
+ /* probe the service and verify ASE identity banner */
25
+ const probe = async (port, projectId) => {
26
+ try {
27
+ const r = await axios.request({
28
+ method: "OPTIONS",
29
+ url: `http://${HOST}:${port}/`,
30
+ timeout: 2000,
31
+ validateStatus: () => true
32
+ });
33
+ if (r.status < 200 || r.status >= 300)
34
+ return false;
35
+ const d = r.data;
36
+ return d?.ase === true && d?.projectId === projectId;
37
+ }
38
+ catch (err) {
39
+ if (isConnRefused(err))
40
+ return null;
41
+ throw err;
42
+ }
43
+ };
44
+ /* CLI command "ase mcp" */
45
+ export default class MCPCommand {
46
+ log;
47
+ constructor(log) {
48
+ this.log = log;
49
+ }
50
+ /* load service identity context */
51
+ loadContext() {
52
+ const cfg = new Config("config", configSchema, this.log);
53
+ cfg.read();
54
+ const svc = new Config("service", serviceSchema, this.log);
55
+ svc.read();
56
+ const rawId = cfg.get("project.id");
57
+ const projectId = rawId ?? path.basename(process.cwd());
58
+ const rawPort = svc.get("port");
59
+ const port = rawPort ?? null;
60
+ return { projectId, port, svc };
61
+ }
62
+ /* spawn "ase service start" detached and wait for it to come up */
63
+ async ensureService() {
64
+ let ctx = this.loadContext();
65
+ /* fast path: already running */
66
+ if (ctx.port !== null) {
67
+ const match = await probe(ctx.port, ctx.projectId);
68
+ if (match === true)
69
+ return { projectId: ctx.projectId, port: ctx.port };
70
+ }
71
+ /* spawn "ase service start" using the same node entry point */
72
+ const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
73
+ await execa(process.execPath, [entry, "service", "start"], {
74
+ stdio: "ignore",
75
+ detached: false
76
+ });
77
+ /* re-load context to pick up the freshly persisted port */
78
+ ctx = this.loadContext();
79
+ if (ctx.port === null)
80
+ throw new Error("mcp: service did not register a port after start");
81
+ const match = await probe(ctx.port, ctx.projectId);
82
+ if (match !== true)
83
+ throw new Error(`mcp: service not responding on port ${ctx.port} after start`);
84
+ return { projectId: ctx.projectId, port: ctx.port };
85
+ }
86
+ /* bridge stdio to a Streamable HTTP MCP endpoint on the local service */
87
+ async runBridge() {
88
+ /* ensure the service is running */
89
+ const { port } = await this.ensureService();
90
+ /* create MCP HTTP client */
91
+ const url = new URL(`http://${HOST}:${port}/mcp`);
92
+ const client = new StreamableHTTPClientTransport(url);
93
+ /* create MCP STDIO server */
94
+ const server = new StdioServerTransport();
95
+ /* handle shutdown */
96
+ let closed = false;
97
+ const shutdown = async () => {
98
+ if (closed)
99
+ return;
100
+ closed = true;
101
+ await Promise.allSettled([
102
+ server.close(),
103
+ client.close()
104
+ ]);
105
+ };
106
+ /* connect server to client (forward transport) */
107
+ server.onmessage = (msg) => {
108
+ client.send(msg).catch((_err) => {
109
+ const err = _err instanceof Error ? _err : new Error(String(_err));
110
+ this.log.write("error", `mcp: http send: ${err.message}`);
111
+ });
112
+ };
113
+ server.onerror = (_err) => {
114
+ const err = _err instanceof Error ? _err : new Error(String(_err));
115
+ this.log.write("error", `mcp: stdio: ${err.message}`);
116
+ };
117
+ server.onclose = () => {
118
+ shutdown().catch(() => { });
119
+ };
120
+ /* connect client to server (backward transport) */
121
+ client.onmessage = (msg) => {
122
+ server.send(msg).catch((_err) => {
123
+ const err = _err instanceof Error ? _err : new Error(String(_err));
124
+ this.log.write("error", `mcp: stdout send: ${err.message}`);
125
+ });
126
+ };
127
+ client.onerror = (_err) => {
128
+ const err = _err instanceof Error ? _err : new Error(String(_err));
129
+ this.log.write("error", `mcp: http: ${err.message}`);
130
+ };
131
+ client.onclose = () => {
132
+ shutdown().catch(() => { });
133
+ };
134
+ /* start server and client */
135
+ await server.start();
136
+ await client.start();
137
+ /* await stdio to be closed */
138
+ await new Promise((resolve) => {
139
+ const done = () => resolve();
140
+ process.stdin.once("end", done);
141
+ process.stdin.once("close", done);
142
+ });
143
+ /* shutdown services */
144
+ await shutdown();
145
+ return 0;
146
+ }
147
+ /* register commands */
148
+ register(program) {
149
+ program
150
+ .command("mcp")
151
+ .description("Bridge stdio MCP to the per-project background service over Streamable HTTP")
152
+ .action(async () => {
153
+ process.exit(await this.runBridge());
154
+ });
155
+ }
156
+ }
@@ -13,7 +13,12 @@ import axios from "axios";
13
13
  import { isMap } from "yaml";
14
14
  import * as v from "valibot";
15
15
  import prettyMs from "pretty-ms";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { z } from "zod";
16
19
  import { Config, configSchema } from "./ase-config.js";
20
+ import { renderDiagram } from "./ase-diagram.js";
21
+ import pkg from "../package.json" with { type: "json" };
17
22
  const SERVE_ENV = "ASE_SERVICE_SERVE";
18
23
  const PORT_ENV = "ASE_SERVICE_PORT";
19
24
  const HOST = "127.0.0.1";
@@ -177,6 +182,86 @@ export default class ServiceCommand {
177
182
  lastActivity = Date.now();
178
183
  return h.continue;
179
184
  });
185
+ /* build a fresh MCP server instance with the demo "ping" tool */
186
+ const buildMcpServer = () => {
187
+ const mcp = new McpServer({ name: "ase", version: pkg.version });
188
+ mcp.registerTool("ping", {
189
+ title: "ASE service ping",
190
+ description: "Return ASE service identity, port, and uptime.",
191
+ inputSchema: {}
192
+ }, async () => {
193
+ const status = {
194
+ ok: true,
195
+ projectId: ctx.projectId,
196
+ port: ctx.port,
197
+ uptimeMs: Date.now() - startTime
198
+ };
199
+ return {
200
+ content: [{ type: "text", text: JSON.stringify(status) }]
201
+ };
202
+ });
203
+ mcp.registerTool("diagram", {
204
+ title: "ASE diagram render",
205
+ description: "Render a Mermaid diagram as Unicode/ASCII art. " +
206
+ "Use for visualizing " +
207
+ "structure/layout/components/dependencies as a Flowchart, " +
208
+ "control-flow/branching/concurrency as a Flowchart, " +
209
+ "state-machine/states/transitions as an UML State Diagram, " +
210
+ "data-flow/actors/messages/protocols as an UML Sequence Diagram, " +
211
+ "data-structure/classes/methods as an UML Class Diagram " +
212
+ "data-model/entities/relationships as an ER Diagram, or " +
213
+ "metrics/distributions/time-series as a XY-Charts. " +
214
+ "Pass the Mermaid diagram specification as `diagram`. " +
215
+ "Returns the rendered art as `text`.",
216
+ inputSchema: {
217
+ diagram: z.string()
218
+ .describe("Mermaid diagram specification"),
219
+ ascii: z.boolean().default(false)
220
+ .describe("emit plain ASCII (+-|) instead of Unicode box-drawing characters"),
221
+ colorMode: z.enum(["none", "ansi16", "ansi256"]).default("none")
222
+ .describe("color mode for ANSI escape sequences in the rendered output"),
223
+ nodeMarginX: z.number().int().min(0).default(3)
224
+ .describe("horizontal margin between nodes, in characters"),
225
+ nodeMarginY: z.number().int().min(0).default(3)
226
+ .describe("vertical margin between nodes, in lines"),
227
+ nodePadding: z.number().int().min(0).default(1)
228
+ .describe("inner horizontal and vertical padding within each node, in characters"),
229
+ diagramClipX: z.number().int().min(0).default(0)
230
+ .describe("extra horizontal clipping: subtract this many characters from `terminalWidth`"),
231
+ diagramClipY: z.number().int().min(0).default(0)
232
+ .describe("extra vertical clipping: subtract this many lines from `terminalHeight`"),
233
+ terminalWidth: z.number().int().min(0).default(0)
234
+ .describe("terminal width in characters; 0 disables horizontal clipping"),
235
+ terminalHeight: z.number().int().min(0).default(0)
236
+ .describe("terminal height in lines; 0 disables vertical clipping")
237
+ }
238
+ }, async (args) => {
239
+ try {
240
+ const out = renderDiagram(args.diagram, {
241
+ ascii: args.ascii,
242
+ colorMode: args.colorMode,
243
+ nodeMarginX: args.nodeMarginX,
244
+ nodeMarginY: args.nodeMarginY,
245
+ nodePadding: args.nodePadding,
246
+ diagramClipX: args.diagramClipX,
247
+ diagramClipY: args.diagramClipY,
248
+ terminalWidth: args.terminalWidth,
249
+ terminalHeight: args.terminalHeight
250
+ });
251
+ return {
252
+ content: [{ type: "text", text: out }]
253
+ };
254
+ }
255
+ catch (err) {
256
+ const message = err instanceof Error ? err.message : String(err);
257
+ return {
258
+ isError: true,
259
+ content: [{ type: "text", text: `diagram: render failed: ${message}` }]
260
+ };
261
+ }
262
+ });
263
+ return mcp;
264
+ };
180
265
  /* listen to HTTP/REST endpoints */
181
266
  server.route({
182
267
  method: "OPTIONS",
@@ -196,6 +281,38 @@ export default class ServiceCommand {
196
281
  return h.response({ ok: true }).code(200);
197
282
  }
198
283
  });
284
+ const mcpHandler = async (request, h, body) => {
285
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
286
+ const mcp = buildMcpServer();
287
+ request.raw.res.on("close", () => {
288
+ transport.close().catch(() => { });
289
+ mcp.close().catch(() => { });
290
+ });
291
+ try {
292
+ await mcp.connect(transport);
293
+ await transport.handleRequest(request.raw.req, request.raw.res, body);
294
+ }
295
+ catch (_err) {
296
+ const err = _err instanceof Error ? _err : new Error(String(_err));
297
+ this.log.write("error", `mcp: ${err.message}`);
298
+ if (!request.raw.res.headersSent) {
299
+ request.raw.res.statusCode = 500;
300
+ request.raw.res.end();
301
+ }
302
+ }
303
+ return h.abandon;
304
+ };
305
+ server.route({
306
+ method: "POST",
307
+ path: "/mcp",
308
+ options: { payload: { parse: true, allow: "application/json" } },
309
+ handler: (request, h) => mcpHandler(request, h, request.payload)
310
+ });
311
+ server.route({
312
+ method: ["GET", "DELETE"],
313
+ path: "/mcp",
314
+ handler: (request, h) => mcpHandler(request, h)
315
+ });
199
316
  server.route({
200
317
  method: "POST",
201
318
  path: "/command",