@protoboardai/cli 0.0.0-stage → 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/src/layout.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `protoboard layout` (PB-1007): the diagram's layout measured, level by
3
+ * level, headless, and written to the display file when asked.
4
+ *
5
+ * The numbers are what the viewer draws (`@deltarobotics/uhd-viewer/headless`:
6
+ * the same layout and line routing): link crossings, overlapping boxes, the
7
+ * strip (the most cards one straight line across or down passes through), the
8
+ * aspect ratio, lines under cards. With --apply each level is laid out afresh
9
+ * (the viewer's automatic layout); with --tidy the saved arrangement is the
10
+ * starting point. Pinned cards keep their places. A level is written only when
11
+ * the result reads better than what is there (--force writes it anyway).
12
+ *
13
+ * `check` adds the findings of the levels of modules (not the boards' sheets,
14
+ * which `layout` reports) as PB_DIAGRAM_LAYOUT info diagnostics.
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { dirname, join, relative } from "node:path";
18
+ import { uhdBundle, type Project, type ProjectDiagnostic } from "@protoboardai/core";
19
+ import { DISPLAY_SCHEMA, formatDisplay, type DisplayFile, type LevelLayout } from "./dev/layout.js";
20
+
21
+ type Headless = typeof import("@deltarobotics/uhd-viewer/headless");
22
+ type LayoutMetrics = import("@deltarobotics/uhd-viewer/headless").LayoutMetrics;
23
+ type LayoutFinding = import("@deltarobotics/uhd-viewer/headless").LayoutFinding;
24
+ type UhdBundle = Parameters<Headless["measureLevel"]>[0];
25
+
26
+ export interface LayoutOptions {
27
+ /** One level (an instance path; "" is the top). Default: every level. */
28
+ level?: string;
29
+ /** Write: "default" lays the level out afresh, "tidy" fixes the saved arrangement. */
30
+ apply?: "default" | "tidy";
31
+ /** Cards that keep their places (added to the level's saved `pinned`). */
32
+ pin?: string[];
33
+ /** Write even when the result does not read better. */
34
+ force?: boolean;
35
+ }
36
+
37
+ export interface LevelReport {
38
+ level: string;
39
+ label: string;
40
+ board: boolean;
41
+ /** The level has a saved layout (else the viewer lays it out automatically). */
42
+ saved: boolean;
43
+ metrics: LayoutMetrics;
44
+ findings: LayoutFinding[];
45
+ /** With --apply or --tidy: the proposal's metrics and findings, and whether it was written. */
46
+ proposed?: { mode: "default" | "tidy"; metrics: LayoutMetrics; findings: LayoutFinding[]; better: boolean; written: boolean; pinned: string[] };
47
+ }
48
+
49
+ export interface LayoutReport {
50
+ /** The display file, relative to the project. */
51
+ file: string;
52
+ levels: LevelReport[];
53
+ /** Levels written to the display file. */
54
+ written: string[];
55
+ /** Levels with a warning after this run (what --check fails on). */
56
+ warnings: number;
57
+ }
58
+
59
+ async function headless(): Promise<Headless> {
60
+ return (await import("@deltarobotics/uhd-viewer/headless")) as Headless;
61
+ }
62
+
63
+ export function displayPath(p: Project): string {
64
+ return join(p.root, p.config.display ?? ".protoboard/display.json");
65
+ }
66
+
67
+ const slim = (m: LayoutMetrics): LayoutMetrics => ({ ...m, detail: { overlaps: m.detail.overlaps.slice(0, 20), underCards: m.detail.underCards.slice(0, 20) } });
68
+
69
+ export async function runLayout(p: Project, opts: LayoutOptions = {}): Promise<LayoutReport> {
70
+ const h = await headless();
71
+ const file = displayPath(p);
72
+ const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined;
73
+ const display: DisplayFile = previous ? (JSON.parse(previous) as DisplayFile) : { schema: DISPLAY_SCHEMA };
74
+ const bundle = (await uhdBundle(p)) as unknown as UhdBundle;
75
+ const all = h.diagramLevels(bundle);
76
+ const levels = opts.level === undefined ? all : all.filter((l) => l.level === opts.level);
77
+ if (!levels.length) throw new Error(`no level "${opts.level}"; the levels are ${all.map((l) => (l.level ? `"${l.level}"` : '"" (the top)')).join(", ")}`);
78
+ const out: LevelReport[] = [];
79
+ const written: string[] = [];
80
+ for (const lv of levels) {
81
+ const now = h.measureLevel(bundle, lv.level);
82
+ const report: LevelReport = { level: lv.level, label: lv.label, board: lv.board, saved: now.saved, metrics: slim(now.metrics), findings: now.findings };
83
+ if (opts.apply) {
84
+ const pr = h.proposeLevel(bundle, lv.level, { mode: opts.apply, pinned: opts.pin });
85
+ // a level with no saved layout already draws the automatic layout, and keeps following the model as it
86
+ // changes: it is saved only when asked (--force), or when cards are pinned on it
87
+ const write = opts.force || (pr.better && now.saved);
88
+ if (write) {
89
+ display.schema ??= DISPLAY_SCHEMA;
90
+ display.diagram ??= {};
91
+ display.diagram.layouts ??= {};
92
+ display.diagram.layouts[lv.level] = pr.layout as LevelLayout;
93
+ written.push(lv.level);
94
+ }
95
+ report.proposed = { mode: opts.apply, metrics: slim(pr.after.metrics), findings: pr.after.findings, better: pr.better, written: write, pinned: pr.layout.pinned ?? [] };
96
+ }
97
+ out.push(report);
98
+ }
99
+ if (written.length) {
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ writeFileSync(file, formatDisplay(display, previous));
102
+ }
103
+ const warnings = out.filter((r) => ((r.proposed?.written ? r.proposed.findings : r.findings) ?? []).some((f) => f.severity === "warning")).length;
104
+ return { file: relative(p.root, file) || file, levels: out, written, warnings };
105
+ }
106
+
107
+ const name = (r: { level: string; label: string }) => (r.level ? `${r.level} (${r.label})` : `top level (${r.label})`);
108
+ const line = (m: LayoutMetrics) =>
109
+ `${m.cards} cards, ${m.links} lines: ${m.crossings} crossings, ${m.overlaps} overlaps, longest ${m.stripAxis} ${m.strip}, aspect ${m.aspect}, ${m.underCards} under cards`;
110
+
111
+ export function formatLayout(r: LayoutReport): string {
112
+ const out: string[] = [];
113
+ for (const l of r.levels) {
114
+ out.push(`${name(l)}${l.board ? " [board]" : ""}: ${l.saved ? "saved layout" : "automatic layout"}`);
115
+ out.push(` now: ${line(l.metrics)}`);
116
+ for (const f of l.findings) out.push(` ${f.severity === "warning" ? "!" : "·"} ${f.message}`);
117
+ // a board's sheet is laid out as a schematic; past a few dozen parts it reads better as sub-modules (model, not display)
118
+ if (l.board && l.findings.some((f) => f.severity === "warning") && l.metrics.cards > 24)
119
+ out.push(` → a board of ${l.metrics.cards} parts reads better split into sub-modules by function (power, processor, sensors, outputs), each a level of its own: model them, the diagram follows`);
120
+ if (l.proposed) {
121
+ const p = l.proposed;
122
+ out.push(` ${p.mode === "tidy" ? "tidied: " : "default: "} ${line(p.metrics)}${p.pinned.length ? ` (pinned: ${p.pinned.join(", ")})` : ""}`);
123
+ for (const f of p.findings) out.push(` ${f.severity === "warning" ? "!" : "·"} ${f.message}`);
124
+ out.push(` ${p.written ? `written to ${r.file}` : !l.saved ? "not written: the level has no saved layout, so the viewer draws this already (--force saves it)" : "not written: it does not read better than what is there (--force writes it)"}`);
125
+ }
126
+ }
127
+ out.push(
128
+ "",
129
+ r.written.length
130
+ ? `wrote ${r.written.length} level${r.written.length === 1 ? "" : "s"} to ${r.file}; look at them in the viewer (protoboard dev) before you call it done`
131
+ : r.warnings
132
+ ? `${r.warnings} level${r.warnings === 1 ? " has" : "s have"} something to fix: move the cards (protoboard dev, or ${r.file}), or try --apply or --tidy`
133
+ : "nothing to fix",
134
+ );
135
+ return out.join("\n");
136
+ }
137
+
138
+ /**
139
+ * Layout findings for `check`: the levels of modules only (a board's sheet is
140
+ * reported by `protoboard layout`), as info, one diagnostic per level.
141
+ */
142
+ export async function layoutDiagnostics(p: Project): Promise<ProjectDiagnostic[]> {
143
+ if (!p.rootDef) return [];
144
+ try {
145
+ const h = await headless();
146
+ const bundle = (await uhdBundle(p)) as unknown as UhdBundle;
147
+ const out: ProjectDiagnostic[] = [];
148
+ for (const lv of h.diagramLevels(bundle)) {
149
+ if (lv.board) continue;
150
+ const m = h.measureLevel(bundle, lv.level);
151
+ const warn = m.findings.filter((f) => f.severity === "warning");
152
+ if (!warn.length) continue;
153
+ out.push({
154
+ code: "PB_DIAGRAM_LAYOUT",
155
+ severity: "info",
156
+ message: `the diagram's ${lv.level ? `level "${lv.level}"` : "top level"} is hard to read: ${warn.map((f) => f.message).join("; ")}. Run \`protoboard layout${lv.level ? ` ${lv.level}` : ""}\`, and fix it (--apply, --tidy, or by moving cards) before you call the build done`,
157
+ entity: lv.level ? { kind: "instance", id: lv.level } : { kind: "project", id: p.name },
158
+ validator: "protoboard/layout",
159
+ });
160
+ }
161
+ return out;
162
+ } catch {
163
+ // no viewer build (a stack without it): no layout findings
164
+ return [];
165
+ }
166
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What an agent is told on connecting to the Protoboard MCP server (PB-930,
3
+ * docs/specs/agents-and-mcp.md §2.2). Claude Code cuts a server's
4
+ * instructions, and each tool's description, at 2 048 characters: the text
5
+ * stays under that (a test checks it), with what matters most first.
6
+ */
7
+
8
+ /** The limit Claude Code applies to instructions and to each tool description. */
9
+ export const DESCRIPTION_LIMIT = 2048;
10
+
11
+ export function serverInstructions(templates: string[]): string {
12
+ const list = templates.length ? templates.join(", ") : "none installed";
13
+ return `Protoboard designs hardware as code: a project is a folder whose system (modules, parts, interfaces, links, harnesses, artifacts) is written in UHD, the open Unified Hardware Description (*.uhd.ts, package @deltarobotics/uhd) and checked, built and released with the \`protoboard\` CLI. These tools run that CLI.
14
+
15
+ This is NOT the Protoboard alpha web app. There is no browser board to open or connect to; do not look for one. Work in the project's files and with these tools; people see the result in the Protoboard VS Code extension.
16
+
17
+ Start with project_status: whether the folder is a project, what is installed, whether the user is signed in.
18
+
19
+ No project yet: project_init (templates: ${list}; none gives a blank project with ext-hardware). Projects use the Protoboard installed on this machine: never run \`npm install\` (only for a project-local stack the user asked for). Add an extension with \`protoboard extensions add <name>\`.
20
+
21
+ In a project, read AGENTS.md first (inside a larger repository it says what the workspace's guidance governs), then the build-a-project skill (skill_read), which gives the order of work and the skill for each stage, and the UHD reference in node_modules/@deltarobotics/uhd/docs. Run project_check before and after every change.
22
+
23
+ Rules:
24
+ - Cite a source for every number; mark assumptions.
25
+ - Never edit generated files (.protoboard/ except display.json, release/) or parts/catalog; a variant is a new part in parts/local.
26
+ - Boards follow the circuit-board skill; never \`tsci\` on a project's board. Another PCB tool only when that path fails, and say so to the user first.
27
+ - When interfaces do not fit, stop and check the datasheet; never invent wires or drop functions.
28
+ - Never put a sign-in, token or key in a file; when a tool says a sign-in is needed, ask the user to sign in; the result says how.
29
+ - These tools never publish, order or upload. A passing check validates the model, not physical readiness.`;
30
+ }
31
+
32
+ /** The longer guide, as a resource: the instructions and the commands an agent runs in a shell. */
33
+ export function guideText(templates: string[]): string {
34
+ return `# Working with Protoboard
35
+
36
+ ${serverInstructions(templates)}
37
+
38
+ ## Commands to run in a shell (not tools)
39
+
40
+ The command is \`protoboard …\`. If the shell does not find it: a new VS Code terminal (the extension adds it), \`~/.protoboard/bin/protoboard\` (\`%USERPROFILE%\\.protoboard\\bin\\protoboard.cmd\` on Windows), or in a project \`./node_modules/.bin/protoboard\`. Never run a bare \`npx protoboard\`: npx would fetch an unrelated public package of that name. Never run \`npm install\` to set a project up or to add something: projects use the Protoboard installed on this machine.
41
+
42
+ - \`protoboard stack link\` in a project whose packages are missing: links them to the Protoboard installed on this machine (no npm).
43
+ - \`protoboard extensions add <name>\`: adds an extension (ext-tscircuit, ext-enclosure-cad, …) and exports its skills.
44
+ - Signing in is the user's to do, never yours: in VS Code the command "Protoboard: Sign In to Protoboard Cloud" (or the account on the page "Protoboard: Open Settings"); in a terminal \`protoboard login\` (a browser, or a code with --device).
45
+ - \`protoboard doctor\`: extensions, external tools and runtimes; \`protoboard tools install <cad|circuit|all>\` installs a runtime.
46
+ - \`protoboard skills read <name>\`: a skill's text, as skill_read gives it.
47
+ - \`protoboard release\`: writes the Manufacturing Package; release_check says first whether it would be refused.
48
+ - \`protoboard --help\`: every command. Add \`--format json\` for machine output.
49
+
50
+ ## The order of work
51
+
52
+ 1. project_status.
53
+ 2. No project: project_init.
54
+ 3. Read AGENTS.md and the build-a-project skill; write requirements in docs/brief.md.
55
+ 4. Find parts with parts_search, add them with parts_add; research a part the library lacks into parts/local with the parts skills.
56
+ 5. Compose the system in src/*.uhd.ts; project_check after each change; project_inspect to see what resolved.
57
+ 6. project_layout: lay the diagram out by function, compact, and fix what it reports before you call the build done.
58
+ 7. project_build, bom, then release_check.
59
+ `;
60
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `protoboard mcp`: the Protoboard MCP server over stdio (PB-930,
3
+ * docs/specs/agents-and-mcp.md §2). It tells an agent what Protoboard is
4
+ * (the instructions) and gives it the CLI's commands as tools (tools.ts).
5
+ * It works with or without a project in the folder it is started in.
6
+ *
7
+ * stdout carries the protocol and nothing else; anything to say goes to
8
+ * stderr, where clients keep a server's log.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { dirname, isAbsolute, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ import { z } from "zod";
19
+ import { findProjectRoot, listTemplates } from "@protoboardai/core";
20
+ import { guideText, serverInstructions } from "./instructions.js";
21
+ import { cliTools, type CliRun, type ToolContext, type ToolOutcome, type ToolSpec } from "./tools.js";
22
+
23
+ /** The CLI that carries this server. */
24
+ export const SELF_CLI = fileURLToPath(new URL("../../bin/protoboard.mjs", import.meta.url));
25
+ const SELF_VERSION = (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version;
26
+
27
+ /** A result longer than this goes to a file; the tool returns where (Claude Code stops a result at 25 000 tokens). */
28
+ export const RESULT_LIMIT = 40_000;
29
+
30
+ /** The `@protoboardai/cli` installed for a folder: the nearest `node_modules` from it up, as Node resolves it. */
31
+ export function projectCli(dir: string): string | undefined {
32
+ for (let d = resolve(dir); ; d = dirname(d)) {
33
+ const bin = join(d, "node_modules", "@protoboardai", "cli", "bin", "protoboard.mjs");
34
+ if (existsSync(bin)) return bin;
35
+ if (dirname(d) === d) return undefined;
36
+ }
37
+ }
38
+
39
+ function runCli(entry: string, args: string[], cwd: string, timeoutMs: number): Promise<CliRun> {
40
+ return new Promise((done) => {
41
+ const child = spawn(process.execPath, [entry, ...args], { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
42
+ let stdout = "";
43
+ let stderr = "";
44
+ child.stdout.on("data", (d) => (stdout += d));
45
+ child.stderr.on("data", (d) => (stderr += d));
46
+ const timer = setTimeout(() => {
47
+ stderr += `\nprotoboard: stopped after ${Math.round(timeoutMs / 1000)} s`;
48
+ child.kill();
49
+ }, timeoutMs);
50
+ child.on("error", (e) => {
51
+ clearTimeout(timer);
52
+ done({ code: null, stdout, stderr: `${stderr}\n${e.message}` });
53
+ });
54
+ child.on("close", (code) => {
55
+ clearTimeout(timer);
56
+ done({ code, stdout, stderr });
57
+ });
58
+ });
59
+ }
60
+
61
+ const SPILL_PREFIX = "protoboard-mcp-";
62
+ const SPILL_KEEP_MS = 24 * 60 * 60 * 1000;
63
+ let spill: string | undefined;
64
+
65
+ /**
66
+ * Remove the folders of earlier servers of this user that are more than a
67
+ * day old (and the one folder earlier versions shared). Only real folders
68
+ * this user owns: the system's temporary folder is shared on Linux.
69
+ */
70
+ export function sweepSpill(now = Date.now(), tmp = tmpdir()): void {
71
+ let names: string[];
72
+ try {
73
+ names = readdirSync(tmp);
74
+ } catch {
75
+ return;
76
+ }
77
+ const uid = process.getuid?.();
78
+ for (const n of names) {
79
+ if (n !== "protoboard-mcp" && !n.startsWith(SPILL_PREFIX)) continue;
80
+ const dir = join(tmp, n);
81
+ try {
82
+ const s = lstatSync(dir);
83
+ if (!s.isDirectory() || dir === spill || (uid !== undefined && s.uid !== uid) || now - s.mtimeMs < SPILL_KEEP_MS) continue;
84
+ rmSync(dir, { recursive: true, force: true });
85
+ } catch {
86
+ // someone else's, or gone already
87
+ }
88
+ }
89
+ }
90
+
91
+ /** This server's own folder for results too long to return: made for this user only (0700), with a name no one can prepare. */
92
+ function spillDir(): string {
93
+ if (!spill || !existsSync(spill)) spill = mkdtempSync(join(tmpdir(), SPILL_PREFIX));
94
+ return spill;
95
+ }
96
+
97
+ /** A tool's outcome as an MCP result: JSON text, moved to a file when it would flood the agent's context. */
98
+ export function toResult(name: string, o: ToolOutcome): { content: { type: "text"; text: string }[]; isError?: boolean } {
99
+ let text = typeof o.data === "string" ? o.data : JSON.stringify(o.data, null, 2);
100
+ if (text.length > RESULT_LIMIT) {
101
+ // a project's BOM or model: readable by this user only, and removed by a later server once it is a day old
102
+ const file = join(spillDir(), `${name}-${Date.now()}.${typeof o.data === "string" ? "md" : "json"}`);
103
+ writeFileSync(file, text, { mode: 0o600 });
104
+ text = JSON.stringify({ truncated: true, characters: text.length, file, note: "The whole result is in `file`: read the part you need (or query it with jq). Its start follows.", start: text.slice(0, 6000) }, null, 2);
105
+ }
106
+ return { content: [{ type: "text", text }], ...(o.isError ? { isError: true } : {}) };
107
+ }
108
+
109
+ export interface ServerOptions {
110
+ /** The folder tools work in when they are given none (default: PROTOBOARD_PROJECT_DIR, the client's first root, the process's folder). */
111
+ cwd?: string;
112
+ /** More tools beside the CLI's (the seam for a bridge to a VS Code window, §7). */
113
+ tools?: ToolSpec[];
114
+ }
115
+
116
+ /** The server, not yet connected to a transport. */
117
+ export function createServer(options: ServerOptions = {}): McpServer {
118
+ sweepSpill();
119
+ const templates = listTemplates().map((t) => t.id);
120
+ const about = serverInstructions(templates);
121
+ const server = new McpServer({ name: "protoboard-stack", title: "Protoboard Stack", version: SELF_VERSION }, { instructions: about });
122
+
123
+ let rootDir: string | undefined;
124
+ const base = async (): Promise<string> => {
125
+ if (options.cwd) return options.cwd;
126
+ if (process.env.PROTOBOARD_PROJECT_DIR) return resolve(process.env.PROTOBOARD_PROJECT_DIR);
127
+ if (rootDir === undefined && server.server.getClientCapabilities()?.roots) {
128
+ try {
129
+ const first = (await server.server.listRoots()).roots.find((r) => r.uri.startsWith("file:"));
130
+ rootDir = first ? fileURLToPath(first.uri) : "";
131
+ } catch {
132
+ rootDir = "";
133
+ }
134
+ }
135
+ return rootDir || process.cwd();
136
+ };
137
+ // a client that changes its roots says so: it is asked again at the next call
138
+ server.server.setNotificationHandler(RootsListChangedNotificationSchema, () => {
139
+ rootDir = undefined;
140
+ });
141
+
142
+ const ctx: ToolContext = {
143
+ dir: async (given) => (given ? (isAbsolute(given) ? given : resolve(await base(), given)) : base()),
144
+ cliFor: (dir) => {
145
+ const own = projectCli(dir);
146
+ return own ? { entry: own, from: "project" } : { entry: SELF_CLI, from: "server" };
147
+ },
148
+ cli: (dir, args, o) => runCli(ctx.cliFor(dir).entry, args, dir, o?.timeoutMs ?? 180_000),
149
+ version: SELF_VERSION,
150
+ about,
151
+ };
152
+
153
+ for (const spec of [...cliTools(templates), ...(options.tools ?? [])]) {
154
+ server.registerTool(spec.name, { title: spec.title, description: spec.description, inputSchema: spec.input, annotations: { title: spec.title, ...spec.annotations } }, async (args: Record<string, unknown>) => {
155
+ try {
156
+ const dir = typeof args.dir === "string" ? await ctx.dir(args.dir) : undefined;
157
+ if (dir && spec.name !== "project_init" && !existsSync(dir)) return toResult(spec.name, { data: { error: `no folder ${dir}` }, isError: true });
158
+ return toResult(spec.name, await spec.handler(args as never, ctx));
159
+ } catch (e) {
160
+ return toResult(spec.name, { data: { error: (e as Error).message }, isError: true });
161
+ }
162
+ });
163
+ }
164
+
165
+ server.registerResource("guide", "protoboard://guide", { title: "Working with Protoboard", description: "What Protoboard is, the rules, the order of work and the shell commands beside these tools.", mimeType: "text/markdown" }, async (uri) => ({
166
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: guideText(templates) }],
167
+ }));
168
+ // the project's own instructions, when the server was started in a project that has them
169
+ const startedIn = options.cwd ?? process.env.PROTOBOARD_PROJECT_DIR ?? process.cwd();
170
+ const root = findProjectRoot(resolve(startedIn));
171
+ if (root && existsSync(join(root, "AGENTS.md"))) {
172
+ server.registerResource("agents-md", "protoboard://project/AGENTS.md", { title: "The project's AGENTS.md", description: "The project's own instructions for agents: read it before working on the project.", mimeType: "text/markdown" }, async (uri) => ({
173
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(join(root, "AGENTS.md"), "utf8") }],
174
+ }));
175
+ }
176
+
177
+ server.registerPrompt(
178
+ "new_project",
179
+ { title: "Start a Protoboard project", description: "Start a hardware project for a device, in this folder.", argsSchema: { what: z.string().describe("The device to build, in a sentence or two.") } },
180
+ ({ what }) => ({
181
+ messages: [
182
+ {
183
+ role: "user",
184
+ content: {
185
+ type: "text",
186
+ text: `Use Protoboard to start a hardware project for: ${what}\n\nCall project_status first. If this folder is not a project yet, pick the closest template (${templates.join(", ")}; or none) and create it with project_init (it uses the Protoboard installed on this machine: never run \`npm install\`). Read the project's AGENTS.md and the build-a-project skill (skill_read), write the requirements into docs/brief.md with a source for every number, and follow the skill from there. Run project_check after each change. Do not look for a Protoboard web app: there is none to use here.`,
187
+ },
188
+ },
189
+ ],
190
+ }),
191
+ );
192
+ server.registerPrompt("continue_project", { title: "Continue this Protoboard project", description: "Pick up the Protoboard project in this folder where it stands." }, () => ({
193
+ messages: [
194
+ {
195
+ role: "user",
196
+ content: {
197
+ type: "text",
198
+ text: "Continue the Protoboard project in this folder. Call project_status, read AGENTS.md and docs/brief.md, run project_check and project_inspect to see where it stands, then say what the next step of the build-a-project skill is (skill_read) and what you would do. Do not change anything before I agree.",
199
+ },
200
+ },
201
+ ],
202
+ }));
203
+ return server;
204
+ }
205
+
206
+ /** Serve on stdio until the client closes the stream. */
207
+ export async function serveStdio(options: ServerOptions = {}): Promise<void> {
208
+ const server = createServer(options);
209
+ await server.connect(new StdioServerTransport());
210
+ process.stderr.write(`protoboard mcp ${SELF_VERSION}: serving on stdio (folder: ${options.cwd ?? process.cwd()})\n`);
211
+ await new Promise<void>((done) => {
212
+ server.server.onclose = () => done();
213
+ process.stdin.on("close", () => done());
214
+ });
215
+ }