@mrfentmen/eagle-cad-mcp 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -2
- package/dist/_xml.d.ts +11 -0
- package/dist/_xml.js +62 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +334 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +111 -0
- package/dist/gateway.d.ts +14 -0
- package/dist/gateway.js +78 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/run.d.ts +17 -0
- package/dist/run.js +15 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +9 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.js +1 -0
- package/package.json +44 -4
- package/server.json +20 -0
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# EAGLE MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
EAGLE MCP: parse schematics and boards, validate parts, nets and sheets.
|
|
4
|
+
|
|
5
|
+
No network calls. Files are parsed locally and nothing leaves the machine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y @mrfentmen/eagle-cad-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in an MCP client:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"eagle-cad-mcp": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["-y", "@mrfentmen/eagle-cad-mcp"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | What it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `detect_files` | Find EAGLE files in a directory tree. |
|
|
31
|
+
| `read_file` | Read a file or line range. |
|
|
32
|
+
| `write_file` | Write a file, with overwrite protection. |
|
|
33
|
+
| `parse` | Parse into records, fields and metadata. |
|
|
34
|
+
| `validate` | Check structural rules and report issues by line. |
|
|
35
|
+
| `summarize` | Record counts, kinds and document metadata. |
|
|
36
|
+
| `list_records` | List record ids and kinds. |
|
|
37
|
+
| `get_record` | Full fields for one record. |
|
|
38
|
+
| `search` | Plain text or regex search across records. |
|
|
39
|
+
| `stats` | Numeric min/max/mean per field. |
|
|
40
|
+
| `format_reference` | Compact spec reference. |
|
|
41
|
+
| `sample` | A small valid example document. |
|
|
42
|
+
| `diff` | *(premium)* Compare two documents. |
|
|
43
|
+
| `to_json` | *(premium)* Export every record as JSON. |
|
|
44
|
+
|
|
45
|
+
<!-- paywall -->
|
|
46
|
+
## Premium tools
|
|
47
|
+
|
|
48
|
+
`diff` and `to_json` need a licence key. Their bodies run on our gateway, not
|
|
49
|
+
inside this package, so the key is what buys access. Buy one at
|
|
50
|
+
https://buy.polar.sh/polar_cl_mrO7rS5LZxpqM7oAFFEA10i9121J240U40IIj4JRZO4
|
|
51
|
+
and set it in your MCP client config:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
"env": { "MCP_LICENSE_KEY": "polar_..." }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every other tool on this server stays free.
|
|
58
|
+
<!-- /paywall -->
|
|
59
|
+
|
|
60
|
+
## Safety
|
|
61
|
+
|
|
62
|
+
This server reads and writes local files. Treat file paths as trusted input.
|
|
63
|
+
Invalid documents are reported, never silently repaired.
|
|
64
|
+
|
|
65
|
+
## Run locally
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install
|
|
69
|
+
npm run build
|
|
70
|
+
node dist/index.js
|
|
71
|
+
```
|
package/dist/_xml.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Issue } from "./types.js";
|
|
2
|
+
export interface XmlTag {
|
|
3
|
+
name: string;
|
|
4
|
+
attrs: Record<string, string>;
|
|
5
|
+
close: boolean;
|
|
6
|
+
selfClose: boolean;
|
|
7
|
+
line: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function scanTags(text: string): XmlTag[];
|
|
10
|
+
export declare function readRoot(text: string): string;
|
|
11
|
+
export declare function validateXml(text: string, root: string, displayName: string, issues: Issue[]): void;
|
package/dist/_xml.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function scanTags(text) {
|
|
2
|
+
const tags = [];
|
|
3
|
+
const re = /<(\/?)([A-Za-z_][\w.:-]*)([^>]*?)(\/?)>/g;
|
|
4
|
+
let match;
|
|
5
|
+
let cursor = 0;
|
|
6
|
+
let line = 1;
|
|
7
|
+
while ((match = re.exec(text)) !== null) {
|
|
8
|
+
line += (text.slice(cursor, match.index).match(/\n/g) ?? []).length;
|
|
9
|
+
cursor = match.index;
|
|
10
|
+
const attrs = {};
|
|
11
|
+
for (const attr of match[3].matchAll(/([\w.:-]+)\s*=\s*"([^"]*)"/g))
|
|
12
|
+
attrs[attr[1]] = attr[2];
|
|
13
|
+
tags.push({ name: match[2], attrs, close: match[1] === "/", selfClose: match[4] === "/", line });
|
|
14
|
+
}
|
|
15
|
+
return tags;
|
|
16
|
+
}
|
|
17
|
+
export function readRoot(text) {
|
|
18
|
+
const declaration = text.replace(/^\s*<\?xml[^>]*\?>/, "");
|
|
19
|
+
const match = /<\s*([A-Za-z_][\w.:-]*)/.exec(declaration);
|
|
20
|
+
return match ? match[1] : "";
|
|
21
|
+
}
|
|
22
|
+
export function validateXml(text, root, displayName, issues) {
|
|
23
|
+
if (text.trim().length === 0) {
|
|
24
|
+
issues.push({ code: "empty", severity: "error", message: "Document is empty" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const tags = scanTags(text);
|
|
28
|
+
if (tags.length === 0) {
|
|
29
|
+
issues.push({ code: "no-tags", severity: "error", message: "No XML elements found" });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const found = readRoot(text);
|
|
33
|
+
if (found !== root) {
|
|
34
|
+
issues.push({ code: "bad-root", severity: "error", message: `Root element must be ${root}, found ${found || "nothing"}` });
|
|
35
|
+
}
|
|
36
|
+
const stack = [];
|
|
37
|
+
for (const tag of tags) {
|
|
38
|
+
if (tag.selfClose)
|
|
39
|
+
continue;
|
|
40
|
+
if (tag.close) {
|
|
41
|
+
const open = stack.pop();
|
|
42
|
+
if (!open) {
|
|
43
|
+
issues.push({ code: "unmatched-close", severity: "error", message: `Closing </${tag.name}> has no opening tag`, line: tag.line });
|
|
44
|
+
}
|
|
45
|
+
else if (open.name !== tag.name) {
|
|
46
|
+
issues.push({
|
|
47
|
+
code: "mismatched-tag",
|
|
48
|
+
severity: "error",
|
|
49
|
+
message: `</${tag.name}> closes <${open.name}> from line ${open.line}`,
|
|
50
|
+
line: tag.line,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
stack.push(tag);
|
|
56
|
+
}
|
|
57
|
+
for (const open of stack.slice(-3)) {
|
|
58
|
+
issues.push({ code: "unclosed-tag", severity: "error", message: `<${open.name}> is never closed`, line: open.line });
|
|
59
|
+
}
|
|
60
|
+
if (!root)
|
|
61
|
+
issues.push({ code: "no-root", severity: "error", message: `${displayName} has no root element` });
|
|
62
|
+
}
|
package/dist/core.d.ts
ADDED
package/dist/core.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { errorMessage, fail, text, ToolError } from "./run.js";
|
|
5
|
+
import { GatewayError, premiumTool } from "./gateway.js";
|
|
6
|
+
const MAX_INPUT = 4_000_000;
|
|
7
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".mcp-build"]);
|
|
8
|
+
const PREMIUM = new Set(["to_json", "diff"]);
|
|
9
|
+
async function readInput(input) {
|
|
10
|
+
if (input.path && input.content !== undefined)
|
|
11
|
+
throw new ToolError("Use path or content, not both");
|
|
12
|
+
if (input.content !== undefined) {
|
|
13
|
+
if (input.content.length > MAX_INPUT)
|
|
14
|
+
throw new ToolError(`Input is larger than ${MAX_INPUT} characters`);
|
|
15
|
+
return { body: input.content };
|
|
16
|
+
}
|
|
17
|
+
if (!input.path)
|
|
18
|
+
throw new ToolError("Provide a path or content");
|
|
19
|
+
if (input.path.includes("\0"))
|
|
20
|
+
throw new ToolError("Path contains a null byte");
|
|
21
|
+
const file = path.isAbsolute(input.path) ? input.path : path.resolve(input.cwd ? path.resolve(input.cwd) : process.cwd(), input.path);
|
|
22
|
+
const stat = await fs.stat(file);
|
|
23
|
+
if (!stat.isFile())
|
|
24
|
+
throw new ToolError(`Not a file: ${file}`);
|
|
25
|
+
if (stat.size > MAX_INPUT)
|
|
26
|
+
throw new ToolError(`File is larger than ${MAX_INPUT} bytes`);
|
|
27
|
+
return { body: await fs.readFile(file, "utf8"), file };
|
|
28
|
+
}
|
|
29
|
+
function parseOrThrow(format, body) {
|
|
30
|
+
const parsed = format.parse(body);
|
|
31
|
+
const errors = parsed.issues.filter((issue) => issue.severity === "error");
|
|
32
|
+
if (errors.length > 0) {
|
|
33
|
+
const first = errors.slice(0, 5).map((issue) => (issue.line ? `line ${issue.line}: ${issue.message}` : issue.message));
|
|
34
|
+
throw new ToolError(`${format.displayName} could not be parsed:\n${first.join("\n")}`);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
function numericStats(records) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const record of records) {
|
|
41
|
+
for (const [key, value] of Object.entries(record.fields)) {
|
|
42
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
43
|
+
continue;
|
|
44
|
+
const current = out[key];
|
|
45
|
+
if (!current) {
|
|
46
|
+
out[key] = { count: 1, min: value, max: value, mean: value, sum: value };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
current.count += 1;
|
|
50
|
+
current.sum += value;
|
|
51
|
+
current.min = Math.min(current.min, value);
|
|
52
|
+
current.max = Math.max(current.max, value);
|
|
53
|
+
current.mean = current.sum / current.count;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function round(value) {
|
|
60
|
+
return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : value;
|
|
61
|
+
}
|
|
62
|
+
export function registerFormatTools(server, format) {
|
|
63
|
+
const sourceArgs = {
|
|
64
|
+
path: z.string().min(1).max(2_000).optional(),
|
|
65
|
+
content: z.string().max(MAX_INPUT).optional(),
|
|
66
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
67
|
+
};
|
|
68
|
+
server.tool("detect_files", `Find ${format.displayName} files (${format.extensions.join(", ")}) in a directory tree.`, {
|
|
69
|
+
path: z.string().min(1).max(2_000).default("."),
|
|
70
|
+
maxDepth: z.number().int().min(0).max(8).default(4),
|
|
71
|
+
}, async (a) => {
|
|
72
|
+
try {
|
|
73
|
+
const root = path.resolve(a.path);
|
|
74
|
+
const stat = await fs.stat(root);
|
|
75
|
+
if (!stat.isDirectory())
|
|
76
|
+
throw new ToolError(`Not a directory: ${root}`);
|
|
77
|
+
const found = [];
|
|
78
|
+
let scanned = 0;
|
|
79
|
+
async function walk(dir, depth) {
|
|
80
|
+
if (depth > a.maxDepth || found.length >= 500)
|
|
81
|
+
return;
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.name.startsWith("."))
|
|
91
|
+
continue;
|
|
92
|
+
const full = path.join(dir, entry.name);
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
if (SKIP_DIRS.has(entry.name))
|
|
95
|
+
continue;
|
|
96
|
+
await walk(full, depth + 1);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.isFile())
|
|
100
|
+
continue;
|
|
101
|
+
scanned += 1;
|
|
102
|
+
if (!format.extensions.includes(path.extname(entry.name).toLowerCase()))
|
|
103
|
+
continue;
|
|
104
|
+
found.push(path.relative(root, full));
|
|
105
|
+
if (found.length >= 500)
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await walk(root, 0);
|
|
110
|
+
return text(JSON.stringify({ format: format.displayName, root, filesScanned: scanned, matches: found.length, files: found }, null, 2));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return fail(errorMessage(error));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
server.tool("read_file", `Read a ${format.displayName} file, optionally a line range.`, {
|
|
117
|
+
path: z.string().min(1).max(2_000),
|
|
118
|
+
startLine: z.number().int().min(1).default(1),
|
|
119
|
+
endLine: z.number().int().min(1).optional(),
|
|
120
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
121
|
+
}, async (a) => {
|
|
122
|
+
try {
|
|
123
|
+
const { body } = await readInput({ path: a.path, cwd: a.cwd });
|
|
124
|
+
const lines = body.split(/\r?\n/);
|
|
125
|
+
const end = Math.min(a.endLine ?? lines.length, lines.length);
|
|
126
|
+
return text(lines.slice(a.startLine - 1, end).join("\n"));
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return fail(errorMessage(error));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
server.tool("write_file", `Write a ${format.displayName} file. Refuses to overwrite unless overwrite is true.`, {
|
|
133
|
+
path: z.string().min(1).max(2_000),
|
|
134
|
+
content: z.string().max(MAX_INPUT),
|
|
135
|
+
overwrite: z.boolean().default(false),
|
|
136
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
137
|
+
}, async (a) => {
|
|
138
|
+
try {
|
|
139
|
+
const file = path.isAbsolute(a.path) ? a.path : path.resolve(a.cwd ? path.resolve(a.cwd) : process.cwd(), a.path);
|
|
140
|
+
if (!a.overwrite) {
|
|
141
|
+
const exists = await fs.stat(file).then(() => true).catch(() => false);
|
|
142
|
+
if (exists)
|
|
143
|
+
throw new ToolError(`File already exists: ${file}. Set overwrite=true to replace it.`);
|
|
144
|
+
}
|
|
145
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
146
|
+
await fs.writeFile(file, a.content, "utf8");
|
|
147
|
+
return text(`Wrote ${Buffer.byteLength(a.content)} bytes to ${file}`);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return fail(errorMessage(error));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
server.tool("parse", `Parse ${format.displayName} into records, fields and document metadata.`, { ...sourceArgs }, async (a) => {
|
|
154
|
+
try {
|
|
155
|
+
const { body, file } = await readInput(a);
|
|
156
|
+
const parsed = parseOrThrow(format, body);
|
|
157
|
+
return text(JSON.stringify({
|
|
158
|
+
file: file ?? null,
|
|
159
|
+
format: parsed.format,
|
|
160
|
+
meta: parsed.meta,
|
|
161
|
+
records: parsed.records.length,
|
|
162
|
+
kinds: parsed.records.reduce((acc, record) => {
|
|
163
|
+
acc[record.kind] = (acc[record.kind] ?? 0) + 1;
|
|
164
|
+
return acc;
|
|
165
|
+
}, {}),
|
|
166
|
+
issues: parsed.issues,
|
|
167
|
+
}, null, 2));
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return fail(errorMessage(error));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
server.tool("validate", `Validate ${format.displayName} against its structural rules and report every issue with a line number.`, { ...sourceArgs }, async (a) => {
|
|
174
|
+
try {
|
|
175
|
+
const { body } = await readInput(a);
|
|
176
|
+
const parsed = format.parse(body);
|
|
177
|
+
const errors = parsed.issues.filter((issue) => issue.severity === "error").length;
|
|
178
|
+
const warnings = parsed.issues.length - errors;
|
|
179
|
+
return text(JSON.stringify({
|
|
180
|
+
format: parsed.format,
|
|
181
|
+
valid: errors === 0,
|
|
182
|
+
errors,
|
|
183
|
+
warnings,
|
|
184
|
+
issues: parsed.issues,
|
|
185
|
+
}, null, 2));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return fail(errorMessage(error));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
server.tool("summarize", `Summarize a ${format.displayName} document: metadata, record counts and per-kind totals.`, { ...sourceArgs }, async (a) => {
|
|
192
|
+
try {
|
|
193
|
+
const { body } = await readInput(a);
|
|
194
|
+
const parsed = parseOrThrow(format, body);
|
|
195
|
+
const kinds = parsed.records.reduce((acc, record) => {
|
|
196
|
+
acc[record.kind] = (acc[record.kind] ?? 0) + 1;
|
|
197
|
+
return acc;
|
|
198
|
+
}, {});
|
|
199
|
+
const lines = [
|
|
200
|
+
`${format.displayName} document`,
|
|
201
|
+
`records: ${parsed.records.length}`,
|
|
202
|
+
...Object.entries(kinds).map(([kind, count]) => ` ${kind}: ${count}`),
|
|
203
|
+
...Object.entries(parsed.meta).map(([key, value]) => `${key}: ${String(value)}`),
|
|
204
|
+
`issues: ${parsed.issues.length}`,
|
|
205
|
+
];
|
|
206
|
+
return text(lines.join("\n"));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return fail(errorMessage(error));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
server.tool("list_records", `List ${format.displayName} record ids and kinds, with a limit.`, {
|
|
213
|
+
...sourceArgs,
|
|
214
|
+
limit: z.number().int().min(1).max(1_000).default(50),
|
|
215
|
+
}, async (a) => {
|
|
216
|
+
try {
|
|
217
|
+
const { body } = await readInput(a);
|
|
218
|
+
const parsed = parseOrThrow(format, body);
|
|
219
|
+
const rows = parsed.records.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
|
|
220
|
+
const rest = parsed.records.length - rows.length;
|
|
221
|
+
return text(rows.join("\n") + (rest > 0 ? `\n... ${rest} more` : ""));
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return fail(errorMessage(error));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
server.tool("get_record", `Return one ${format.displayName} record by id, with all parsed fields.`, {
|
|
228
|
+
...sourceArgs,
|
|
229
|
+
id: z.string().min(1).max(300),
|
|
230
|
+
}, async (a) => {
|
|
231
|
+
try {
|
|
232
|
+
const { body } = await readInput(a);
|
|
233
|
+
const parsed = parseOrThrow(format, body);
|
|
234
|
+
const record = parsed.records.find((item) => item.id === a.id);
|
|
235
|
+
if (!record)
|
|
236
|
+
throw new ToolError(`No record with id ${a.id}. Use list_records to see available ids.`);
|
|
237
|
+
return text(JSON.stringify(record, null, 2));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return fail(errorMessage(error));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
server.tool("search", `Search ${format.displayName} records by plain text or regular expression.`, {
|
|
244
|
+
...sourceArgs,
|
|
245
|
+
query: z.string().min(1).max(500),
|
|
246
|
+
regex: z.boolean().default(false),
|
|
247
|
+
limit: z.number().int().min(1).max(500).default(50),
|
|
248
|
+
}, async (a) => {
|
|
249
|
+
try {
|
|
250
|
+
const { body } = await readInput(a);
|
|
251
|
+
const parsed = parseOrThrow(format, body);
|
|
252
|
+
let test;
|
|
253
|
+
if (a.regex) {
|
|
254
|
+
let pattern;
|
|
255
|
+
try {
|
|
256
|
+
pattern = new RegExp(a.query, "i");
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw new ToolError(`Invalid regular expression: ${a.query}`);
|
|
260
|
+
}
|
|
261
|
+
test = (value) => pattern.test(value);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const needle = a.query.toLowerCase();
|
|
265
|
+
test = (value) => value.toLowerCase().includes(needle);
|
|
266
|
+
}
|
|
267
|
+
const matches = parsed.records.filter((record) => test(`${record.id} ${record.kind} ${JSON.stringify(record.fields)}`));
|
|
268
|
+
const rows = matches.slice(0, a.limit).map((record) => `${record.kind}\t${record.id}`);
|
|
269
|
+
return text(`${matches.length} match(es)\n${rows.join("\n")}`.trim());
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return fail(errorMessage(error));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
server.tool("stats", `Numeric statistics for ${format.displayName} fields across all records.`, {
|
|
276
|
+
...sourceArgs,
|
|
277
|
+
field: z.string().min(1).max(120).optional(),
|
|
278
|
+
}, async (a) => {
|
|
279
|
+
try {
|
|
280
|
+
const { body } = await readInput(a);
|
|
281
|
+
const parsed = parseOrThrow(format, body);
|
|
282
|
+
const stats = numericStats(parsed.records);
|
|
283
|
+
if (a.field) {
|
|
284
|
+
const one = stats[a.field];
|
|
285
|
+
if (!one)
|
|
286
|
+
throw new ToolError(`Field ${a.field} has no numeric values. Numeric fields: ${Object.keys(stats).join(", ") || "none"}`);
|
|
287
|
+
return text(JSON.stringify({ [a.field]: { ...one, mean: round(one.mean) } }, null, 2));
|
|
288
|
+
}
|
|
289
|
+
const rounded = Object.fromEntries(Object.entries(stats).map(([key, value]) => [key, { ...value, mean: round(value.mean) }]));
|
|
290
|
+
return text(JSON.stringify(rounded, null, 2));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return fail(errorMessage(error));
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
server.tool("to_json", `Export every parsed ${format.displayName} record as JSON. Premium: needs MCP_LICENSE_KEY.`, { ...sourceArgs }, async (a) => {
|
|
297
|
+
try {
|
|
298
|
+
if (PREMIUM.has("to_json")) {
|
|
299
|
+
const { body, file } = await readInput(a);
|
|
300
|
+
return text(await premiumTool("to_json", { format: format.slug, file: file ?? null, content: body }));
|
|
301
|
+
}
|
|
302
|
+
return fail("to_json is not configured");
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error instanceof GatewayError)
|
|
306
|
+
return fail(error.message);
|
|
307
|
+
return fail(errorMessage(error));
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
server.tool("diff", `Compare two ${format.displayName} documents: added, removed and changed records. Premium: needs MCP_LICENSE_KEY.`, {
|
|
311
|
+
leftPath: z.string().min(1).max(2_000).optional(),
|
|
312
|
+
leftContent: z.string().max(MAX_INPUT).optional(),
|
|
313
|
+
rightPath: z.string().min(1).max(2_000).optional(),
|
|
314
|
+
rightContent: z.string().max(MAX_INPUT).optional(),
|
|
315
|
+
cwd: z.string().min(1).max(2_000).optional(),
|
|
316
|
+
}, async (a) => {
|
|
317
|
+
try {
|
|
318
|
+
const left = await readInput({ path: a.leftPath, content: a.leftContent, cwd: a.cwd });
|
|
319
|
+
const right = await readInput({ path: a.rightPath, content: a.rightContent, cwd: a.cwd });
|
|
320
|
+
return text(await premiumTool("diff", {
|
|
321
|
+
format: format.slug,
|
|
322
|
+
left: left.body,
|
|
323
|
+
right: right.body,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof GatewayError)
|
|
328
|
+
return fail(error.message);
|
|
329
|
+
return fail(errorMessage(error));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
server.tool("format_reference", `Return a compact ${format.displayName} reference: structure, required fields and common pitfalls.`, {}, async () => text(format.reference));
|
|
333
|
+
server.tool("sample", `Return a small but valid ${format.displayName} example document.`, {}, async () => text(format.sample()));
|
|
334
|
+
}
|
package/dist/format.d.ts
ADDED
package/dist/format.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { scanTags, validateXml } from "./_xml.js";
|
|
2
|
+
function parse(text) {
|
|
3
|
+
const issues = [];
|
|
4
|
+
validateXml(text, "eagle", "EAGLE file", issues);
|
|
5
|
+
const tags = scanTags(text);
|
|
6
|
+
const records = [];
|
|
7
|
+
const parts = new Set();
|
|
8
|
+
const nets = new Set();
|
|
9
|
+
let sheets = 0;
|
|
10
|
+
for (const tag of tags) {
|
|
11
|
+
if (tag.close)
|
|
12
|
+
continue;
|
|
13
|
+
if (tag.name === "part") {
|
|
14
|
+
const name = tag.attrs.name ?? "";
|
|
15
|
+
const library = tag.attrs.library ?? "";
|
|
16
|
+
const deviceset = tag.attrs.deviceset ?? "";
|
|
17
|
+
if (!name) {
|
|
18
|
+
issues.push({ code: "part-without-name", severity: "error", message: "part has no name", line: tag.line });
|
|
19
|
+
}
|
|
20
|
+
else if (parts.has(name)) {
|
|
21
|
+
issues.push({ code: "duplicate-part", severity: "error", message: `Part ${name} appears twice`, line: tag.line });
|
|
22
|
+
}
|
|
23
|
+
parts.add(name);
|
|
24
|
+
if (!library || !deviceset) {
|
|
25
|
+
issues.push({ code: "part-without-device", severity: "error", message: `Part ${name || tag.line} needs library and deviceset`, line: tag.line });
|
|
26
|
+
}
|
|
27
|
+
records.push({ id: `part:${name || tag.line}`, kind: "part", fields: { name, library, deviceset, device: tag.attrs.device ?? "", line: tag.line } });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (tag.name === "net") {
|
|
31
|
+
const name = tag.attrs.name ?? "";
|
|
32
|
+
if (!name)
|
|
33
|
+
issues.push({ code: "net-without-name", severity: "error", message: "net has no name", line: tag.line });
|
|
34
|
+
nets.add(name);
|
|
35
|
+
records.push({ id: `net:${name || tag.line}`, kind: "net", fields: { name, line: tag.line } });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (tag.name === "sheet") {
|
|
39
|
+
sheets += 1;
|
|
40
|
+
records.push({ id: `sheet:${sheets}`, kind: "sheet", fields: { line: tag.line } });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (tag.name === "signal") {
|
|
44
|
+
records.push({ id: `signal:${tag.attrs.name ?? tag.line}`, kind: "signal", fields: { name: tag.attrs.name ?? "", line: tag.line } });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!/\beagle\b/.test(text)) {
|
|
48
|
+
issues.push({ code: "no-eagle", severity: "error", message: "File has no eagle root" });
|
|
49
|
+
}
|
|
50
|
+
if (parts.size === 0 && nets.size === 0) {
|
|
51
|
+
issues.push({ code: "empty-design", severity: "warning", message: "Design has no parts and no nets" });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
format: "EAGLE",
|
|
55
|
+
records,
|
|
56
|
+
meta: {
|
|
57
|
+
parts: parts.size,
|
|
58
|
+
nets: nets.size,
|
|
59
|
+
sheets,
|
|
60
|
+
signals: records.filter((record) => record.kind === "signal").length,
|
|
61
|
+
},
|
|
62
|
+
issues,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const reference = [
|
|
66
|
+
"EAGLE schematic or board (XML)",
|
|
67
|
+
"",
|
|
68
|
+
"<eagle version=\"9.6\">",
|
|
69
|
+
" <schematic>",
|
|
70
|
+
" <parts>",
|
|
71
|
+
" <part name=\"R1\" library=\"rcl\" deviceset=\"R\" device=\"\"/>",
|
|
72
|
+
" </parts>",
|
|
73
|
+
" <sheets>",
|
|
74
|
+
" <sheet>",
|
|
75
|
+
" <nets>",
|
|
76
|
+
" <net name=\"GND\"/>",
|
|
77
|
+
" </nets>",
|
|
78
|
+
" </sheet>",
|
|
79
|
+
" </sheets>",
|
|
80
|
+
" </schematic>",
|
|
81
|
+
"</eagle>",
|
|
82
|
+
"",
|
|
83
|
+
"The root element is eagle.",
|
|
84
|
+
"Every part needs name, library and deviceset.",
|
|
85
|
+
"Part names must be unique; nets need a name.",
|
|
86
|
+
].join("\n");
|
|
87
|
+
export const format = {
|
|
88
|
+
slug: "eagle-cad",
|
|
89
|
+
displayName: "EAGLE",
|
|
90
|
+
extensions: [".sch", ".brd"],
|
|
91
|
+
description: "Parse EAGLE schematics and boards, validate parts, nets and sheets.",
|
|
92
|
+
reference,
|
|
93
|
+
parse,
|
|
94
|
+
sample: () => [
|
|
95
|
+
'<?xml version="1.0"?>',
|
|
96
|
+
'<eagle version="9.6">',
|
|
97
|
+
" <schematic>",
|
|
98
|
+
" <parts>",
|
|
99
|
+
' <part name="R1" library="rcl" deviceset="R" device=""/>',
|
|
100
|
+
" </parts>",
|
|
101
|
+
" <sheets>",
|
|
102
|
+
" <sheet>",
|
|
103
|
+
" <nets>",
|
|
104
|
+
' <net name="GND"/>',
|
|
105
|
+
" </nets>",
|
|
106
|
+
" </sheet>",
|
|
107
|
+
" </sheets>",
|
|
108
|
+
" </schematic>",
|
|
109
|
+
"</eagle>",
|
|
110
|
+
].join("\n") + "\n",
|
|
111
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* Premium tool bodies run on the gateway, which checks a Polar licence key
|
|
5
|
+
* before it does any work, so removing anything here buys nothing.
|
|
6
|
+
*
|
|
7
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
8
|
+
* endpoint, which is only useful for testing.
|
|
9
|
+
*/
|
|
10
|
+
export declare class GatewayError extends Error {
|
|
11
|
+
readonly refused: boolean;
|
|
12
|
+
constructor(message: string, refused: boolean);
|
|
13
|
+
}
|
|
14
|
+
export declare function premiumTool(tool: string, args: Record<string, unknown>): Promise<string>;
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* Premium tool bodies run on the gateway, which checks a Polar licence key
|
|
5
|
+
* before it does any work, so removing anything here buys nothing.
|
|
6
|
+
*
|
|
7
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
8
|
+
* endpoint, which is only useful for testing.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_GATEWAY_URL = "https://mcp-gateway.contactae2000.workers.dev/mcp";
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 25000;
|
|
12
|
+
const USER_AGENT = "mrfentmen-mcp/1.0";
|
|
13
|
+
export class GatewayError extends Error {
|
|
14
|
+
refused;
|
|
15
|
+
constructor(message, refused) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.refused = refused;
|
|
18
|
+
this.name = "GatewayError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function premiumTool(tool, args) {
|
|
22
|
+
const key = (process.env.MCP_LICENSE_KEY ?? "").trim();
|
|
23
|
+
if (!key) {
|
|
24
|
+
throw new GatewayError(`"${tool}" is a premium tool and no licence key is set. Set MCP_LICENSE_KEY in your MCP client config.`, true);
|
|
25
|
+
}
|
|
26
|
+
const url = (process.env.MCP_GATEWAY_URL ?? DEFAULT_GATEWAY_URL).trim();
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
29
|
+
let response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"content-type": "application/json",
|
|
35
|
+
accept: "application/json",
|
|
36
|
+
"user-agent": USER_AGENT,
|
|
37
|
+
authorization: `Bearer ${key}`,
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
jsonrpc: "2.0",
|
|
41
|
+
id: 1,
|
|
42
|
+
method: "tools/call",
|
|
43
|
+
params: { name: tool, arguments: args },
|
|
44
|
+
}),
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway. Check your connection and try again.`, false);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway (HTTP ${response.status}).`, false);
|
|
56
|
+
}
|
|
57
|
+
let body;
|
|
58
|
+
try {
|
|
59
|
+
body = (await response.json());
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new GatewayError(`"${tool}" got an unreadable answer from the licence gateway.`, false);
|
|
63
|
+
}
|
|
64
|
+
if (body.error) {
|
|
65
|
+
throw new GatewayError(`"${tool}" was refused by the licence gateway: ${body.error.message ?? "unknown error"}`, false);
|
|
66
|
+
}
|
|
67
|
+
const result = body.result ?? {};
|
|
68
|
+
const text = (result.content ?? []).map((part) => part.text ?? "").join("\n").trim();
|
|
69
|
+
if (result.isError) {
|
|
70
|
+
if (result.structuredContent?.licenceRefusal) {
|
|
71
|
+
throw new GatewayError(text || `"${tool}" needs a valid licence key.`, true);
|
|
72
|
+
}
|
|
73
|
+
throw new GatewayError(text || `"${tool}" failed on the licence gateway.`, false);
|
|
74
|
+
}
|
|
75
|
+
if (!text)
|
|
76
|
+
throw new GatewayError(`"${tool}" returned no data.`, false);
|
|
77
|
+
return text;
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer } from "./server.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
|
8
|
+
}
|
|
9
|
+
main().catch((error) => {
|
|
10
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11
|
+
process.stderr.write(`${message}\n`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class ToolError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function errorMessage(error: unknown): string;
|
|
5
|
+
export declare function text(value: string): {
|
|
6
|
+
content: {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
export declare function fail(message: string): {
|
|
12
|
+
isError: boolean;
|
|
13
|
+
content: {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}[];
|
|
17
|
+
};
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class ToolError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "ToolError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function errorMessage(error) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
export function text(value) {
|
|
11
|
+
return { content: [{ type: "text", text: value }] };
|
|
12
|
+
}
|
|
13
|
+
export function fail(message) {
|
|
14
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
15
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { format } from "./format.js";
|
|
3
|
+
import { registerFormatTools } from "./core.js";
|
|
4
|
+
export const VERSION = "1.0.0";
|
|
5
|
+
export function createServer() {
|
|
6
|
+
const server = new McpServer({ name: `${format.slug}-mcp`, version: VERSION });
|
|
7
|
+
registerFormatTools(server, format);
|
|
8
|
+
return server;
|
|
9
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Severity = "error" | "warning";
|
|
2
|
+
export interface Issue {
|
|
3
|
+
code: string;
|
|
4
|
+
severity: Severity;
|
|
5
|
+
message: string;
|
|
6
|
+
line?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ParsedRecord {
|
|
9
|
+
id: string;
|
|
10
|
+
kind: string;
|
|
11
|
+
fields: Record<string, string | number | boolean | null>;
|
|
12
|
+
}
|
|
13
|
+
export interface Parsed {
|
|
14
|
+
format: string;
|
|
15
|
+
records: ParsedRecord[];
|
|
16
|
+
meta: Record<string, string | number | boolean | null>;
|
|
17
|
+
issues: Issue[];
|
|
18
|
+
}
|
|
19
|
+
export interface FormatModule {
|
|
20
|
+
slug: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
extensions: string[];
|
|
23
|
+
description: string;
|
|
24
|
+
reference: string;
|
|
25
|
+
parse(text: string): Parsed;
|
|
26
|
+
sample(): string;
|
|
27
|
+
regressions?: Array<{
|
|
28
|
+
doc: string;
|
|
29
|
+
expectError: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/eagle-cad-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "EAGLE MCP: parse schematics and boards, validate parts, nets and sheets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"mcpName": "io.github.mrfentmen/eagle-cad-mcp",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mrfentmen/awesome-servers.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"eagle-cad-mcp": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"server.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"test": "npm run build && node --test tests/tools.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"eagle",
|
|
29
|
+
"cad",
|
|
30
|
+
"pcb",
|
|
31
|
+
"electronics",
|
|
32
|
+
"schematic"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
37
|
+
"zod": "^3.23.8"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"typescript": "^5.6.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.mrfentmen/eagle-cad-mcp",
|
|
4
|
+
"description": "EAGLE MCP: parse schematics and boards, validate parts, nets and sheets.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/mrfentmen/awesome-servers",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "@mrfentmen/eagle-cad-mcp",
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|