lilygo-doc-mcp 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/.claude/settings.json +10 -0
- package/Dockerfile +26 -0
- package/README.md +65 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +341 -0
- package/package.json +33 -0
- package/src/index.ts +394 -0
- package/tsconfig.json +15 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
FROM node:22-alpine AS builder
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
COPY package*.json ./
|
|
6
|
+
RUN npm ci
|
|
7
|
+
|
|
8
|
+
COPY tsconfig.json ./
|
|
9
|
+
COPY src/ ./src/
|
|
10
|
+
RUN npm run build
|
|
11
|
+
|
|
12
|
+
FROM node:22-alpine
|
|
13
|
+
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
|
|
16
|
+
COPY package*.json ./
|
|
17
|
+
RUN npm ci --omit=dev
|
|
18
|
+
|
|
19
|
+
COPY --from=builder /app/dist ./dist
|
|
20
|
+
|
|
21
|
+
ENV PORT=3000
|
|
22
|
+
ENV GITHUB_RAW_BASE=https://raw.githubusercontent.com/Xinyuan-LilyGO/documentation/master/en/products
|
|
23
|
+
|
|
24
|
+
EXPOSE 3000
|
|
25
|
+
|
|
26
|
+
CMD ["node", "dist/index.js"]
|
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# lilygo-doc-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [LILYGO](https://www.lilygo.cc) product documentation. Exposes LILYGO hardware docs as structured tools for LLM clients via the [Model Context Protocol](https://modelcontextprotocol.io).
|
|
4
|
+
|
|
5
|
+
Documentation is fetched live from [Xinyuan-LilyGO/documentation](https://github.com/Xinyuan-LilyGO/documentation) on GitHub and cached in memory for 10 minutes.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
### With npx (recommended)
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"lilygo-docs": {
|
|
15
|
+
"type": "stdio",
|
|
16
|
+
"command": "npx",
|
|
17
|
+
"args": ["-y", "lilygo-doc-mcp"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Install globally
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g lilygo-doc-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"lilygo-docs": {
|
|
33
|
+
"type": "stdio",
|
|
34
|
+
"command": "lilygo-doc-mcp"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | Description |
|
|
43
|
+
|------|-------------|
|
|
44
|
+
| `list_products` | List all products, filter by series / tags / keyword |
|
|
45
|
+
| `get_product` | Get full docs or a specific section (overview, quickstart, features, parameters, pins, faq) |
|
|
46
|
+
| `search_products` | Full-text search across all product docs with ranked excerpts |
|
|
47
|
+
| `get_product_specs` | Extract structured specs: key features, parameter table, pin tables |
|
|
48
|
+
|
|
49
|
+
## Environment variables
|
|
50
|
+
|
|
51
|
+
| Variable | Default | Description |
|
|
52
|
+
|----------|---------|-------------|
|
|
53
|
+
| `GITHUB_RAW_BASE` | `https://raw.githubusercontent.com/Xinyuan-LilyGO/documentation/master/en/products` | Base URL for fetching markdown files |
|
|
54
|
+
|
|
55
|
+
## Run locally
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install
|
|
59
|
+
npm run build
|
|
60
|
+
npm start
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// ── Config ─────────────────────────────────────────────────────────────────
|
|
6
|
+
const GITHUB_RAW_BASE = process.env.GITHUB_RAW_BASE ??
|
|
7
|
+
"https://raw.githubusercontent.com/Xinyuan-LilyGO/documentation/master/en/products";
|
|
8
|
+
// ── GitHub index fetching ──────────────────────────────────────────────────
|
|
9
|
+
const KNOWN_CATEGORIES = [
|
|
10
|
+
"industrial-series",
|
|
11
|
+
"other",
|
|
12
|
+
"t-beam-series",
|
|
13
|
+
"t-camera-series",
|
|
14
|
+
"t-connect-series",
|
|
15
|
+
"t-deck-series",
|
|
16
|
+
"t-display-series",
|
|
17
|
+
"t-dongle-series",
|
|
18
|
+
"t-echo-series",
|
|
19
|
+
"t-embed-series",
|
|
20
|
+
"t-encoder-series",
|
|
21
|
+
"t-eth-series",
|
|
22
|
+
"t-halow-series",
|
|
23
|
+
"t-lora-series",
|
|
24
|
+
"t-relay-series",
|
|
25
|
+
"t-sim-series",
|
|
26
|
+
"t-twr-series",
|
|
27
|
+
"t-watch-series",
|
|
28
|
+
"t3-series",
|
|
29
|
+
];
|
|
30
|
+
async function fetchText(url) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
return null;
|
|
35
|
+
return await res.text();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ── Markdown parsing helpers ───────────────────────────────────────────────
|
|
42
|
+
function parseFrontmatter(raw) {
|
|
43
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
44
|
+
if (!match)
|
|
45
|
+
return { data: {}, body: raw };
|
|
46
|
+
const data = {};
|
|
47
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
48
|
+
const idx = line.indexOf(":");
|
|
49
|
+
if (idx > 0) {
|
|
50
|
+
data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { data, body: match[2] };
|
|
54
|
+
}
|
|
55
|
+
function extractShopLink(body) {
|
|
56
|
+
const m = body.match(/<ShopLink href="([^"]+)"/);
|
|
57
|
+
return m ? m[1] : "";
|
|
58
|
+
}
|
|
59
|
+
function extractSection(body, heading) {
|
|
60
|
+
const lines = body.split(/\r?\n/);
|
|
61
|
+
let inside = false;
|
|
62
|
+
let sectionLevel = 2;
|
|
63
|
+
const collected = [];
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const hMatch = line.match(/^(#{1,4}) /);
|
|
66
|
+
if (hMatch) {
|
|
67
|
+
const level = hMatch[1].length;
|
|
68
|
+
if (inside) {
|
|
69
|
+
if (level <= sectionLevel)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
else if (heading.test(line)) {
|
|
73
|
+
inside = true;
|
|
74
|
+
sectionLevel = level;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (inside)
|
|
79
|
+
collected.push(line);
|
|
80
|
+
}
|
|
81
|
+
return collected.join("\n").trim();
|
|
82
|
+
}
|
|
83
|
+
function parseMarkdownTables(text) {
|
|
84
|
+
const results = [];
|
|
85
|
+
const lines = text.split(/\r?\n/);
|
|
86
|
+
let headers = null;
|
|
87
|
+
let rows = [];
|
|
88
|
+
const flush = () => {
|
|
89
|
+
if (headers && rows.length)
|
|
90
|
+
results.push(rows);
|
|
91
|
+
headers = null;
|
|
92
|
+
rows = [];
|
|
93
|
+
};
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line.trim().startsWith("|")) {
|
|
96
|
+
flush();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const cells = line.split("|").slice(1, -1).map((c) => c.trim());
|
|
100
|
+
if (cells.every((c) => /^[-: ]+$/.test(c)))
|
|
101
|
+
continue;
|
|
102
|
+
if (!headers) {
|
|
103
|
+
headers = cells;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const row = {};
|
|
107
|
+
headers.forEach((h, i) => { row[h] = cells[i] ?? ""; });
|
|
108
|
+
rows.push(row);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
flush();
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
// ── Product index (fetched from GitHub, cached in memory) ─────────────────
|
|
115
|
+
let _cache = null;
|
|
116
|
+
let _cacheTs = 0;
|
|
117
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
118
|
+
async function fetchManifest() {
|
|
119
|
+
const url = GITHUB_RAW_BASE.replace(/\/en\/products$/, "") + "/en/products/manifest.json";
|
|
120
|
+
const text = await fetchText(url);
|
|
121
|
+
if (!text)
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function getProducts() {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (_cache && now - _cacheTs < CACHE_TTL_MS)
|
|
133
|
+
return _cache;
|
|
134
|
+
const manifest = await fetchManifest();
|
|
135
|
+
let entries;
|
|
136
|
+
if (manifest) {
|
|
137
|
+
entries = manifest;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
entries = [];
|
|
141
|
+
for (const category of KNOWN_CATEGORIES) {
|
|
142
|
+
const indexUrl = `${GITHUB_RAW_BASE}/${category}/index.md`;
|
|
143
|
+
const text = await fetchText(indexUrl);
|
|
144
|
+
if (!text)
|
|
145
|
+
continue;
|
|
146
|
+
const links = [...text.matchAll(/\[.*?\]\(([a-z0-9-]+)\/?(?:index\.md)?\)/gi)];
|
|
147
|
+
for (const [, product] of links) {
|
|
148
|
+
if (product && product !== "index") {
|
|
149
|
+
entries.push({ category, product });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const products = [];
|
|
155
|
+
await Promise.all(entries.map(async ({ category, product }) => {
|
|
156
|
+
const url = `${GITHUB_RAW_BASE}/${category}/${product}/index.md`;
|
|
157
|
+
const raw = await fetchText(url);
|
|
158
|
+
if (!raw)
|
|
159
|
+
return;
|
|
160
|
+
const { data, body } = parseFrontmatter(raw);
|
|
161
|
+
products.push({
|
|
162
|
+
category,
|
|
163
|
+
product,
|
|
164
|
+
title: data.title ?? product,
|
|
165
|
+
tags: data.tags ? data.tags.split(",").map((t) => t.trim()) : [],
|
|
166
|
+
shopLink: extractShopLink(body),
|
|
167
|
+
});
|
|
168
|
+
}));
|
|
169
|
+
_cache = products;
|
|
170
|
+
_cacheTs = now;
|
|
171
|
+
return products;
|
|
172
|
+
}
|
|
173
|
+
async function fetchProductBody(meta) {
|
|
174
|
+
const url = `${GITHUB_RAW_BASE}/${meta.category}/${meta.product}/index.md`;
|
|
175
|
+
const raw = await fetchText(url);
|
|
176
|
+
if (!raw)
|
|
177
|
+
return "";
|
|
178
|
+
return parseFrontmatter(raw).body;
|
|
179
|
+
}
|
|
180
|
+
async function findProduct(name) {
|
|
181
|
+
const lower = name.toLowerCase().replace(/\s+/g, "-");
|
|
182
|
+
const products = await getProducts();
|
|
183
|
+
return products.find((p) => p.product.toLowerCase() === lower ||
|
|
184
|
+
p.title.toLowerCase() === name.toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
// ── MCP Server ─────────────────────────────────────────────────────────────
|
|
187
|
+
const server = new McpServer({ name: "lilygo-docs", version: "1.0.0" });
|
|
188
|
+
server.registerTool("list_products", {
|
|
189
|
+
description: "List LILYGO products. Filter by series (e.g. 't-display-series'), tags (e.g. 'ESP32-S3,LoRa'), or keyword.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
series: z.string().optional().describe("Filter by series folder name, e.g. 't-deck-series'"),
|
|
192
|
+
tags: z.string().optional().describe("Comma-separated tags to filter by, e.g. 'LoRa,GPS'"),
|
|
193
|
+
keyword: z.string().optional().describe("Keyword to match against title or tags"),
|
|
194
|
+
},
|
|
195
|
+
}, async ({ series, tags, keyword }) => {
|
|
196
|
+
let products = await getProducts();
|
|
197
|
+
if (series) {
|
|
198
|
+
const s = series.toLowerCase();
|
|
199
|
+
products = products.filter((p) => p.category.toLowerCase() === s);
|
|
200
|
+
}
|
|
201
|
+
if (tags) {
|
|
202
|
+
const filterTags = tags.split(",").map((t) => t.trim().toLowerCase());
|
|
203
|
+
products = products.filter((p) => filterTags.every((ft) => p.tags.some((pt) => pt.toLowerCase().includes(ft))));
|
|
204
|
+
}
|
|
205
|
+
if (keyword) {
|
|
206
|
+
const kw = keyword.toLowerCase();
|
|
207
|
+
products = products.filter((p) => p.title.toLowerCase().includes(kw) ||
|
|
208
|
+
p.product.toLowerCase().includes(kw) ||
|
|
209
|
+
p.tags.some((t) => t.toLowerCase().includes(kw)));
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: "text", text: JSON.stringify(products.map(({ category, product, title, tags, shopLink }) => ({ category, product, title, tags, shopLink })), null, 2) }],
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
server.registerTool("get_product", {
|
|
216
|
+
description: "Get the documentation for a specific LILYGO product. Returns the full markdown or a specific section.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
product: z.string().describe("Product name, e.g. 't-deck', 'T-Lora Pager', 't-display-s3-amoled'"),
|
|
219
|
+
section: z
|
|
220
|
+
.enum(["all", "overview", "quickstart", "features", "parameters", "pins", "faq"])
|
|
221
|
+
.optional()
|
|
222
|
+
.default("all")
|
|
223
|
+
.describe("Section to return. Defaults to 'all'."),
|
|
224
|
+
},
|
|
225
|
+
}, async ({ product, section }) => {
|
|
226
|
+
const meta = await findProduct(product);
|
|
227
|
+
if (!meta) {
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: "text", text: `Product "${product}" not found. Use list_products to see available products.` }],
|
|
230
|
+
isError: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const body = await fetchProductBody(meta);
|
|
234
|
+
if (!body) {
|
|
235
|
+
return { content: [{ type: "text", text: `Failed to fetch documentation for "${product}".` }], isError: true };
|
|
236
|
+
}
|
|
237
|
+
let text;
|
|
238
|
+
switch (section) {
|
|
239
|
+
case "overview":
|
|
240
|
+
text = extractSection(body, /overview/i);
|
|
241
|
+
break;
|
|
242
|
+
case "quickstart":
|
|
243
|
+
text = extractSection(body, /quick.?start/i);
|
|
244
|
+
break;
|
|
245
|
+
case "features":
|
|
246
|
+
text = extractSection(body, /key.?features/i);
|
|
247
|
+
break;
|
|
248
|
+
case "parameters":
|
|
249
|
+
text = extractSection(body, /product.?param/i);
|
|
250
|
+
break;
|
|
251
|
+
case "pins":
|
|
252
|
+
text = extractSection(body, /pin.?(diagram|map)/i);
|
|
253
|
+
break;
|
|
254
|
+
case "faq":
|
|
255
|
+
text = extractSection(body, /^#{1,3} faq/i);
|
|
256
|
+
break;
|
|
257
|
+
default: text = body;
|
|
258
|
+
}
|
|
259
|
+
if (!text) {
|
|
260
|
+
text = section === "all" ? body : `Section "${section}" not found in ${meta.title} documentation.`;
|
|
261
|
+
}
|
|
262
|
+
return { content: [{ type: "text", text: `# ${meta.title}\n\n${text}` }] };
|
|
263
|
+
});
|
|
264
|
+
server.registerTool("search_products", {
|
|
265
|
+
description: "Full-text search across all LILYGO product documentation. Returns matching products with context excerpts.",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
query: z.string().describe("Search query, e.g. 'SX1262 LoRa GPS', 'e-paper display', 'BQ25896'"),
|
|
268
|
+
max_results: z.number().int().min(1).max(20).optional().default(10),
|
|
269
|
+
},
|
|
270
|
+
}, async ({ query, max_results }) => {
|
|
271
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
|
|
272
|
+
const products = await getProducts();
|
|
273
|
+
const results = [];
|
|
274
|
+
await Promise.all(products.map(async (meta) => {
|
|
275
|
+
const body = await fetchProductBody(meta);
|
|
276
|
+
if (!body)
|
|
277
|
+
return;
|
|
278
|
+
const lower = body.toLowerCase();
|
|
279
|
+
let score = 0;
|
|
280
|
+
const excerpts = [];
|
|
281
|
+
const lines = body.split(/\r?\n/);
|
|
282
|
+
for (const term of terms) {
|
|
283
|
+
let pos = 0;
|
|
284
|
+
let found = 0;
|
|
285
|
+
while ((pos = lower.indexOf(term, pos)) !== -1 && found < 3) {
|
|
286
|
+
let charCount = 0;
|
|
287
|
+
for (const line of lines) {
|
|
288
|
+
charCount += line.length + 1;
|
|
289
|
+
if (charCount > pos) {
|
|
290
|
+
const excerpt = line.trim();
|
|
291
|
+
if (excerpt.length > 5 && !excerpts.includes(excerpt)) {
|
|
292
|
+
excerpts.push(excerpt.slice(0, 120));
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
score++;
|
|
298
|
+
found++;
|
|
299
|
+
pos += term.length;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (score > 0) {
|
|
303
|
+
results.push({ category: meta.category, product: meta.product, title: meta.title, shopLink: meta.shopLink, score, excerpts: excerpts.slice(0, 3) });
|
|
304
|
+
}
|
|
305
|
+
}));
|
|
306
|
+
results.sort((a, b) => b.score - a.score);
|
|
307
|
+
const top = results.slice(0, max_results).map(({ score: _s, ...r }) => r);
|
|
308
|
+
const text = top.length ? JSON.stringify(top, null, 2) : `No products found matching "${query}".`;
|
|
309
|
+
return { content: [{ type: "text", text }] };
|
|
310
|
+
});
|
|
311
|
+
server.registerTool("get_product_specs", {
|
|
312
|
+
description: "Extract structured specifications from a LILYGO product: parameters table, pin mapping, and key features.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
product: z.string().describe("Product name, e.g. 't-deck', 't-lora-pager'"),
|
|
315
|
+
},
|
|
316
|
+
}, async ({ product }) => {
|
|
317
|
+
const meta = await findProduct(product);
|
|
318
|
+
if (!meta) {
|
|
319
|
+
return {
|
|
320
|
+
content: [{ type: "text", text: `Product "${product}" not found. Use list_products to see available products.` }],
|
|
321
|
+
isError: true,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const body = await fetchProductBody(meta);
|
|
325
|
+
if (!body) {
|
|
326
|
+
return { content: [{ type: "text", text: `Failed to fetch documentation for "${product}".` }], isError: true };
|
|
327
|
+
}
|
|
328
|
+
const featuresSection = extractSection(body, /key.?features/i);
|
|
329
|
+
const keyFeatures = featuresSection
|
|
330
|
+
.split(/\r?\n/)
|
|
331
|
+
.filter((l) => l.trim().startsWith("-"))
|
|
332
|
+
.map((l) => l.replace(/^[-*]\s*/, "").trim());
|
|
333
|
+
const paramTables = parseMarkdownTables(extractSection(body, /product.?param/i));
|
|
334
|
+
const pinTables = parseMarkdownTables(extractSection(body, /pin.?(diagram|map)/i));
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: JSON.stringify({ title: meta.title, category: meta.category, product: meta.product, tags: meta.tags, shopLink: meta.shopLink, keyFeatures, parameters: paramTables[0] ?? [], pinTables }, null, 2) }],
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
// ── Start ──────────────────────────────────────────────────────────────────
|
|
340
|
+
const transport = new StdioServerTransport();
|
|
341
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lilygo-doc-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for LILYGO product documentation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"lilygo-doc-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"lilygo",
|
|
20
|
+
"esp32",
|
|
21
|
+
"documentation",
|
|
22
|
+
"iot"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
27
|
+
"zod": "^3.25.67"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.15.21",
|
|
31
|
+
"typescript": "^5.8.3"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// ── Config ─────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const GITHUB_RAW_BASE =
|
|
9
|
+
process.env.GITHUB_RAW_BASE ??
|
|
10
|
+
"https://raw.githubusercontent.com/Xinyuan-LilyGO/documentation/master/en/products";
|
|
11
|
+
|
|
12
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface ProductMeta {
|
|
15
|
+
category: string;
|
|
16
|
+
product: string;
|
|
17
|
+
title: string;
|
|
18
|
+
tags: string[];
|
|
19
|
+
shopLink: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── GitHub index fetching ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const KNOWN_CATEGORIES = [
|
|
25
|
+
"industrial-series",
|
|
26
|
+
"other",
|
|
27
|
+
"t-beam-series",
|
|
28
|
+
"t-camera-series",
|
|
29
|
+
"t-connect-series",
|
|
30
|
+
"t-deck-series",
|
|
31
|
+
"t-display-series",
|
|
32
|
+
"t-dongle-series",
|
|
33
|
+
"t-echo-series",
|
|
34
|
+
"t-embed-series",
|
|
35
|
+
"t-encoder-series",
|
|
36
|
+
"t-eth-series",
|
|
37
|
+
"t-halow-series",
|
|
38
|
+
"t-lora-series",
|
|
39
|
+
"t-relay-series",
|
|
40
|
+
"t-sim-series",
|
|
41
|
+
"t-twr-series",
|
|
42
|
+
"t-watch-series",
|
|
43
|
+
"t3-series",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
async function fetchText(url: string): Promise<string | null> {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url);
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
return await res.text();
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Markdown parsing helpers ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function parseFrontmatter(raw: string): { data: Record<string, string>; body: string } {
|
|
59
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
60
|
+
if (!match) return { data: {}, body: raw };
|
|
61
|
+
const data: Record<string, string> = {};
|
|
62
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
63
|
+
const idx = line.indexOf(":");
|
|
64
|
+
if (idx > 0) {
|
|
65
|
+
data[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { data, body: match[2] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractShopLink(body: string): string {
|
|
72
|
+
const m = body.match(/<ShopLink href="([^"]+)"/);
|
|
73
|
+
return m ? m[1] : "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractSection(body: string, heading: RegExp): string {
|
|
77
|
+
const lines = body.split(/\r?\n/);
|
|
78
|
+
let inside = false;
|
|
79
|
+
let sectionLevel = 2;
|
|
80
|
+
const collected: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const hMatch = line.match(/^(#{1,4}) /);
|
|
84
|
+
if (hMatch) {
|
|
85
|
+
const level = hMatch[1].length;
|
|
86
|
+
if (inside) {
|
|
87
|
+
if (level <= sectionLevel) break;
|
|
88
|
+
} else if (heading.test(line)) {
|
|
89
|
+
inside = true;
|
|
90
|
+
sectionLevel = level;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (inside) collected.push(line);
|
|
95
|
+
}
|
|
96
|
+
return collected.join("\n").trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseMarkdownTables(text: string): Array<Record<string, string>[]> {
|
|
100
|
+
const results: Array<Record<string, string>[]> = [];
|
|
101
|
+
const lines = text.split(/\r?\n/);
|
|
102
|
+
let headers: string[] | null = null;
|
|
103
|
+
let rows: Record<string, string>[] = [];
|
|
104
|
+
|
|
105
|
+
const flush = () => {
|
|
106
|
+
if (headers && rows.length) results.push(rows);
|
|
107
|
+
headers = null;
|
|
108
|
+
rows = [];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
if (!line.trim().startsWith("|")) { flush(); continue; }
|
|
113
|
+
const cells = line.split("|").slice(1, -1).map((c) => c.trim());
|
|
114
|
+
if (cells.every((c) => /^[-: ]+$/.test(c))) continue;
|
|
115
|
+
if (!headers) {
|
|
116
|
+
headers = cells;
|
|
117
|
+
} else {
|
|
118
|
+
const row: Record<string, string> = {};
|
|
119
|
+
headers.forEach((h, i) => { row[h] = cells[i] ?? ""; });
|
|
120
|
+
rows.push(row);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flush();
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Product index (fetched from GitHub, cached in memory) ─────────────────
|
|
128
|
+
|
|
129
|
+
let _cache: ProductMeta[] | null = null;
|
|
130
|
+
let _cacheTs = 0;
|
|
131
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
132
|
+
|
|
133
|
+
async function fetchManifest(): Promise<Array<{ category: string; product: string }> | null> {
|
|
134
|
+
const url = GITHUB_RAW_BASE.replace(/\/en\/products$/, "") + "/en/products/manifest.json";
|
|
135
|
+
const text = await fetchText(url);
|
|
136
|
+
if (!text) return null;
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(text) as Array<{ category: string; product: string }>;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function getProducts(): Promise<ProductMeta[]> {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (_cache && now - _cacheTs < CACHE_TTL_MS) return _cache;
|
|
147
|
+
|
|
148
|
+
const manifest = await fetchManifest();
|
|
149
|
+
let entries: Array<{ category: string; product: string }>;
|
|
150
|
+
|
|
151
|
+
if (manifest) {
|
|
152
|
+
entries = manifest;
|
|
153
|
+
} else {
|
|
154
|
+
entries = [];
|
|
155
|
+
for (const category of KNOWN_CATEGORIES) {
|
|
156
|
+
const indexUrl = `${GITHUB_RAW_BASE}/${category}/index.md`;
|
|
157
|
+
const text = await fetchText(indexUrl);
|
|
158
|
+
if (!text) continue;
|
|
159
|
+
const links = [...text.matchAll(/\[.*?\]\(([a-z0-9-]+)\/?(?:index\.md)?\)/gi)];
|
|
160
|
+
for (const [, product] of links) {
|
|
161
|
+
if (product && product !== "index") {
|
|
162
|
+
entries.push({ category, product });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const products: ProductMeta[] = [];
|
|
169
|
+
await Promise.all(
|
|
170
|
+
entries.map(async ({ category, product }) => {
|
|
171
|
+
const url = `${GITHUB_RAW_BASE}/${category}/${product}/index.md`;
|
|
172
|
+
const raw = await fetchText(url);
|
|
173
|
+
if (!raw) return;
|
|
174
|
+
const { data, body } = parseFrontmatter(raw);
|
|
175
|
+
products.push({
|
|
176
|
+
category,
|
|
177
|
+
product,
|
|
178
|
+
title: data.title ?? product,
|
|
179
|
+
tags: data.tags ? data.tags.split(",").map((t) => t.trim()) : [],
|
|
180
|
+
shopLink: extractShopLink(body),
|
|
181
|
+
});
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
_cache = products;
|
|
186
|
+
_cacheTs = now;
|
|
187
|
+
return products;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function fetchProductBody(meta: ProductMeta): Promise<string> {
|
|
191
|
+
const url = `${GITHUB_RAW_BASE}/${meta.category}/${meta.product}/index.md`;
|
|
192
|
+
const raw = await fetchText(url);
|
|
193
|
+
if (!raw) return "";
|
|
194
|
+
return parseFrontmatter(raw).body;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function findProduct(name: string): Promise<ProductMeta | undefined> {
|
|
198
|
+
const lower = name.toLowerCase().replace(/\s+/g, "-");
|
|
199
|
+
const products = await getProducts();
|
|
200
|
+
return products.find(
|
|
201
|
+
(p) =>
|
|
202
|
+
p.product.toLowerCase() === lower ||
|
|
203
|
+
p.title.toLowerCase() === name.toLowerCase()
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── MCP Server ─────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const server = new McpServer({ name: "lilygo-docs", version: "1.0.0" });
|
|
210
|
+
|
|
211
|
+
server.registerTool(
|
|
212
|
+
"list_products",
|
|
213
|
+
{
|
|
214
|
+
description: "List LILYGO products. Filter by series (e.g. 't-display-series'), tags (e.g. 'ESP32-S3,LoRa'), or keyword.",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
series: z.string().optional().describe("Filter by series folder name, e.g. 't-deck-series'"),
|
|
217
|
+
tags: z.string().optional().describe("Comma-separated tags to filter by, e.g. 'LoRa,GPS'"),
|
|
218
|
+
keyword: z.string().optional().describe("Keyword to match against title or tags"),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
async ({ series, tags, keyword }) => {
|
|
222
|
+
let products = await getProducts();
|
|
223
|
+
|
|
224
|
+
if (series) {
|
|
225
|
+
const s = series.toLowerCase();
|
|
226
|
+
products = products.filter((p) => p.category.toLowerCase() === s);
|
|
227
|
+
}
|
|
228
|
+
if (tags) {
|
|
229
|
+
const filterTags = tags.split(",").map((t) => t.trim().toLowerCase());
|
|
230
|
+
products = products.filter((p) =>
|
|
231
|
+
filterTags.every((ft) => p.tags.some((pt) => pt.toLowerCase().includes(ft)))
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (keyword) {
|
|
235
|
+
const kw = keyword.toLowerCase();
|
|
236
|
+
products = products.filter(
|
|
237
|
+
(p) =>
|
|
238
|
+
p.title.toLowerCase().includes(kw) ||
|
|
239
|
+
p.product.toLowerCase().includes(kw) ||
|
|
240
|
+
p.tags.some((t) => t.toLowerCase().includes(kw))
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: JSON.stringify(products.map(({ category, product, title, tags, shopLink }) => ({ category, product, title, tags, shopLink })), null, 2) }],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
server.registerTool(
|
|
251
|
+
"get_product",
|
|
252
|
+
{
|
|
253
|
+
description: "Get the documentation for a specific LILYGO product. Returns the full markdown or a specific section.",
|
|
254
|
+
inputSchema: {
|
|
255
|
+
product: z.string().describe("Product name, e.g. 't-deck', 'T-Lora Pager', 't-display-s3-amoled'"),
|
|
256
|
+
section: z
|
|
257
|
+
.enum(["all", "overview", "quickstart", "features", "parameters", "pins", "faq"])
|
|
258
|
+
.optional()
|
|
259
|
+
.default("all")
|
|
260
|
+
.describe("Section to return. Defaults to 'all'."),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
async ({ product, section }) => {
|
|
264
|
+
const meta = await findProduct(product);
|
|
265
|
+
if (!meta) {
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: `Product "${product}" not found. Use list_products to see available products.` }],
|
|
268
|
+
isError: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const body = await fetchProductBody(meta);
|
|
273
|
+
if (!body) {
|
|
274
|
+
return { content: [{ type: "text", text: `Failed to fetch documentation for "${product}".` }], isError: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let text: string;
|
|
278
|
+
switch (section) {
|
|
279
|
+
case "overview": text = extractSection(body, /overview/i); break;
|
|
280
|
+
case "quickstart": text = extractSection(body, /quick.?start/i); break;
|
|
281
|
+
case "features": text = extractSection(body, /key.?features/i); break;
|
|
282
|
+
case "parameters": text = extractSection(body, /product.?param/i); break;
|
|
283
|
+
case "pins": text = extractSection(body, /pin.?(diagram|map)/i); break;
|
|
284
|
+
case "faq": text = extractSection(body, /^#{1,3} faq/i); break;
|
|
285
|
+
default: text = body;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!text) {
|
|
289
|
+
text = section === "all" ? body : `Section "${section}" not found in ${meta.title} documentation.`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { content: [{ type: "text", text: `# ${meta.title}\n\n${text}` }] };
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
server.registerTool(
|
|
297
|
+
"search_products",
|
|
298
|
+
{
|
|
299
|
+
description: "Full-text search across all LILYGO product documentation. Returns matching products with context excerpts.",
|
|
300
|
+
inputSchema: {
|
|
301
|
+
query: z.string().describe("Search query, e.g. 'SX1262 LoRa GPS', 'e-paper display', 'BQ25896'"),
|
|
302
|
+
max_results: z.number().int().min(1).max(20).optional().default(10),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
async ({ query, max_results }) => {
|
|
306
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
|
|
307
|
+
const products = await getProducts();
|
|
308
|
+
|
|
309
|
+
const results: Array<{ category: string; product: string; title: string; shopLink: string; score: number; excerpts: string[] }> = [];
|
|
310
|
+
|
|
311
|
+
await Promise.all(
|
|
312
|
+
products.map(async (meta) => {
|
|
313
|
+
const body = await fetchProductBody(meta);
|
|
314
|
+
if (!body) return;
|
|
315
|
+
const lower = body.toLowerCase();
|
|
316
|
+
let score = 0;
|
|
317
|
+
const excerpts: string[] = [];
|
|
318
|
+
const lines = body.split(/\r?\n/);
|
|
319
|
+
|
|
320
|
+
for (const term of terms) {
|
|
321
|
+
let pos = 0;
|
|
322
|
+
let found = 0;
|
|
323
|
+
while ((pos = lower.indexOf(term, pos)) !== -1 && found < 3) {
|
|
324
|
+
let charCount = 0;
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
charCount += line.length + 1;
|
|
327
|
+
if (charCount > pos) {
|
|
328
|
+
const excerpt = line.trim();
|
|
329
|
+
if (excerpt.length > 5 && !excerpts.includes(excerpt)) {
|
|
330
|
+
excerpts.push(excerpt.slice(0, 120));
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
score++;
|
|
336
|
+
found++;
|
|
337
|
+
pos += term.length;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (score > 0) {
|
|
342
|
+
results.push({ category: meta.category, product: meta.product, title: meta.title, shopLink: meta.shopLink, score, excerpts: excerpts.slice(0, 3) });
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
results.sort((a, b) => b.score - a.score);
|
|
348
|
+
const top = results.slice(0, max_results).map(({ score: _s, ...r }) => r);
|
|
349
|
+
const text = top.length ? JSON.stringify(top, null, 2) : `No products found matching "${query}".`;
|
|
350
|
+
return { content: [{ type: "text", text }] };
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
server.registerTool(
|
|
355
|
+
"get_product_specs",
|
|
356
|
+
{
|
|
357
|
+
description: "Extract structured specifications from a LILYGO product: parameters table, pin mapping, and key features.",
|
|
358
|
+
inputSchema: {
|
|
359
|
+
product: z.string().describe("Product name, e.g. 't-deck', 't-lora-pager'"),
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
async ({ product }) => {
|
|
363
|
+
const meta = await findProduct(product);
|
|
364
|
+
if (!meta) {
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: `Product "${product}" not found. Use list_products to see available products.` }],
|
|
367
|
+
isError: true,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const body = await fetchProductBody(meta);
|
|
372
|
+
if (!body) {
|
|
373
|
+
return { content: [{ type: "text", text: `Failed to fetch documentation for "${product}".` }], isError: true };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const featuresSection = extractSection(body, /key.?features/i);
|
|
377
|
+
const keyFeatures = featuresSection
|
|
378
|
+
.split(/\r?\n/)
|
|
379
|
+
.filter((l) => l.trim().startsWith("-"))
|
|
380
|
+
.map((l) => l.replace(/^[-*]\s*/, "").trim());
|
|
381
|
+
|
|
382
|
+
const paramTables = parseMarkdownTables(extractSection(body, /product.?param/i));
|
|
383
|
+
const pinTables = parseMarkdownTables(extractSection(body, /pin.?(diagram|map)/i));
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
content: [{ type: "text", text: JSON.stringify({ title: meta.title, category: meta.category, product: meta.product, tags: meta.tags, shopLink: meta.shopLink, keyFeatures, parameters: paramTables[0] ?? [], pinTables }, null, 2) }],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// ── Start ──────────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const transport = new StdioServerTransport();
|
|
394
|
+
await server.connect(transport);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|