@newtui/mcp 0.0.0-stage → 0.1.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 +60 -2
- package/dist/index.js +504 -0
- package/package.json +60 -4
package/README.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @newtui/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for the [newt/ui](https://newtui.dev) documentation — Discord-inspired components for React, Vue and plain HTML/CSS. It gives your editor's agent the docs themselves: installation per framework, theming, the token reference, accessibility notes and the registry format, read from the site rather than recalled from memory.
|
|
4
|
+
|
|
5
|
+
It is the companion to `newtui mcp`, the server inside the CLI: that one answers _which components exist and what their source is_; this one answers _what the documentation says_. Run both.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @newtui/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
Every tool is read-only.
|
|
14
|
+
|
|
15
|
+
| Tool | What it returns |
|
|
16
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `list_docs` | Every page with its title, route, description and section. `section` filters to one heading of the index, e.g. `Installation`. |
|
|
18
|
+
| `search_docs` | Pages ranked by where every keyword appears — title, route, description, section. `content: true` searches page bodies too and adds an excerpt. |
|
|
19
|
+
| `get_doc` | One page as Markdown. Takes a route (`/docs/theming`), a slug (`button`, `installation/nuxt`) or a full URL; an unknown page answers with matches. |
|
|
20
|
+
|
|
21
|
+
## Configure your client
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"newtui-docs": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "@newtui/mcp@latest"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That shape works for Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`) and most other clients. Claude Code can also add it in one line:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add newtui-docs -- npx -y @newtui/mcp@latest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Pointing it at another site
|
|
41
|
+
|
|
42
|
+
The site resolves as `--url` > `NEWTUI_DOCS_URL` > `https://newtui.dev`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @newtui/mcp --url http://localhost:4321
|
|
46
|
+
NEWTUI_DOCS_URL=http://localhost:4321 npx @newtui/mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The server reads what the site publishes for agents: `llms.txt` for the page index, `llms-full.txt` for full-text search, and the `.md` twin of every page. The Blume dev server serves the twins but not the two `llms` files, so against `localhost:4321` the index comes from the JSON docs API (`/api/docs/pages.json`) and `search_docs` falls back to titles and descriptions — the answer says so in `searchedContent`. A built site (`pnpm --filter docs build`, then any static server on `apps/docs/dist`) has everything.
|
|
50
|
+
|
|
51
|
+
Responses are cached in memory for five minutes; a 404 is cached too, a server error is not.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm build # tsdown -> dist/index.js
|
|
57
|
+
pnpm typecheck # tsc --noEmit
|
|
58
|
+
pnpm test # vitest, with the network replaced by an in-memory site
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
newt/ui is an independent, community-built project. It is visually inspired by Discord's client but is not affiliated with, endorsed by, or sponsored by Discord Inc.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
//#region package.json
|
|
8
|
+
var version = "0.1.0";
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/typeof.js
|
|
12
|
+
function _typeof(o) {
|
|
13
|
+
"@babel/helpers - typeof";
|
|
14
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
15
|
+
return typeof o;
|
|
16
|
+
} : function(o) {
|
|
17
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
18
|
+
}, _typeof(o);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/toPrimitive.js
|
|
23
|
+
function toPrimitive(t, r) {
|
|
24
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
25
|
+
var e = t[Symbol.toPrimitive];
|
|
26
|
+
if (void 0 !== e) {
|
|
27
|
+
var i = e.call(t, r || "default");
|
|
28
|
+
if ("object" != _typeof(i)) return i;
|
|
29
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
30
|
+
}
|
|
31
|
+
return ("string" === r ? String : Number)(t);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/toPropertyKey.js
|
|
36
|
+
function toPropertyKey(t) {
|
|
37
|
+
var i = toPrimitive(t, "string");
|
|
38
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/defineProperty.js
|
|
43
|
+
function _defineProperty(e, r, t) {
|
|
44
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
45
|
+
value: t,
|
|
46
|
+
enumerable: !0,
|
|
47
|
+
configurable: !0,
|
|
48
|
+
writable: !0
|
|
49
|
+
}) : e[r] = t, e;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/checkPrivateRedeclaration.js
|
|
54
|
+
function _checkPrivateRedeclaration(e, t) {
|
|
55
|
+
if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/classPrivateMethodInitSpec.js
|
|
60
|
+
function _classPrivateMethodInitSpec(e, a) {
|
|
61
|
+
_checkPrivateRedeclaration(e, a), a.add(e);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/classPrivateFieldInitSpec.js
|
|
66
|
+
function _classPrivateFieldInitSpec(e, t, a) {
|
|
67
|
+
_checkPrivateRedeclaration(e, t), t.set(e, a);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/assertClassBrand.js
|
|
72
|
+
function _assertClassBrand(e, t, n) {
|
|
73
|
+
if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n;
|
|
74
|
+
throw new TypeError("Private element is not present on this object");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/classPrivateFieldSet2.js
|
|
79
|
+
function _classPrivateFieldSet2(s, a, r) {
|
|
80
|
+
return s.set(_assertClassBrand(s, a), r), r;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region \0@oxc-project+runtime@0.154.0/helpers/esm/classPrivateFieldGet2.js
|
|
85
|
+
function _classPrivateFieldGet2(s, a) {
|
|
86
|
+
return s.get(_assertClassBrand(s, a));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/docs.ts
|
|
91
|
+
const DEFAULT_DOCS_URL = "https://newtui.dev";
|
|
92
|
+
/** How long a fetched document stays fresh, in milliseconds. */
|
|
93
|
+
const DEFAULT_CACHE_TTL = 3e5;
|
|
94
|
+
var DocsRequestError = class extends Error {
|
|
95
|
+
constructor(url, status) {
|
|
96
|
+
super(`GET ${url} answered ${status}`);
|
|
97
|
+
_defineProperty(this, "url", void 0);
|
|
98
|
+
_defineProperty(this, "status", void 0);
|
|
99
|
+
this.name = "DocsRequestError";
|
|
100
|
+
this.url = url;
|
|
101
|
+
this.status = status;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** The subset of Blume's JSON docs API the fallback index reads. */
|
|
105
|
+
const pagesApiSchema = z.object({ pages: z.array(z.object({
|
|
106
|
+
route: z.string(),
|
|
107
|
+
title: z.string(),
|
|
108
|
+
description: z.string().optional(),
|
|
109
|
+
contentType: z.string().optional()
|
|
110
|
+
})) });
|
|
111
|
+
/** The docs site resolves as `--url` > `NEWTUI_DOCS_URL` > the default. */
|
|
112
|
+
function resolveDocsUrl(flag, env) {
|
|
113
|
+
return [flag, env].find((value) => value !== void 0 && value.trim() !== "")?.trim() ?? "https://newtui.dev";
|
|
114
|
+
}
|
|
115
|
+
/** Strip the trailing slash so `${base}${path}` never doubles one. */
|
|
116
|
+
function normaliseBaseUrl(url) {
|
|
117
|
+
return new URL(url).toString().replace(/\/+$/u, "");
|
|
118
|
+
}
|
|
119
|
+
const DEFAULT_HOST = new URL(DEFAULT_DOCS_URL).host;
|
|
120
|
+
function pagePathOf(href, base) {
|
|
121
|
+
const link = new URL(href, base);
|
|
122
|
+
if (link.host !== base.host && link.host !== DEFAULT_HOST) return null;
|
|
123
|
+
const path = link.pathname.replace(/\/+$/u, "") || "/";
|
|
124
|
+
const segments = path.split("/");
|
|
125
|
+
const last = segments.at(-1) ?? "";
|
|
126
|
+
if (segments.some((segment) => segment.startsWith(".")) || last.includes(".")) return null;
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
const LINK_LINE = /^- \[(?<title>[^\]]+)\]\((?<href>[^)\s]+)\)(?::\s*(?<description>.*))?$/u;
|
|
130
|
+
/** Parse an `llms.txt` page index into entries rebased on `baseUrl`. */
|
|
131
|
+
function parseLlmsTxt(source, baseUrl) {
|
|
132
|
+
const base = new URL(baseUrl);
|
|
133
|
+
const entries = [];
|
|
134
|
+
const seen = /* @__PURE__ */ new Set();
|
|
135
|
+
let section;
|
|
136
|
+
for (const line of source.split("\n")) {
|
|
137
|
+
if (line.startsWith("## ")) {
|
|
138
|
+
section = line.slice(3).trim();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const groups = LINK_LINE.exec(line.trim())?.groups;
|
|
142
|
+
if (groups?.title === void 0 || groups.href === void 0) continue;
|
|
143
|
+
const path = pagePathOf(groups.href, base);
|
|
144
|
+
if (path === null || seen.has(path)) continue;
|
|
145
|
+
seen.add(path);
|
|
146
|
+
entries.push(toEntry(baseUrl, path, groups.title, groups.description?.trim(), section));
|
|
147
|
+
}
|
|
148
|
+
return entries;
|
|
149
|
+
}
|
|
150
|
+
/** Parse Blume's `/api/docs/pages.json` into the same entries. */
|
|
151
|
+
function parsePagesApi(source, baseUrl) {
|
|
152
|
+
const { pages } = pagesApiSchema.parse(JSON.parse(source));
|
|
153
|
+
return pages.filter((page) => page.route !== "/404").map((page) => toEntry(baseUrl, page.route.replace(/\/+$/u, "") || "/", page.title, page.description, page.contentType));
|
|
154
|
+
}
|
|
155
|
+
function parseLlmsFull(source, baseUrl) {
|
|
156
|
+
const base = new URL(baseUrl);
|
|
157
|
+
const lines = source.split("\n");
|
|
158
|
+
const starts = [];
|
|
159
|
+
for (let index = 1; index < lines.length; index++) {
|
|
160
|
+
const href = /^Source: (?<href>\S+)$/u.exec(lines[index] ?? "")?.groups?.href;
|
|
161
|
+
if (href === void 0 || !(lines[index - 1] ?? "").startsWith("# ")) continue;
|
|
162
|
+
const path = pagePathOf(href, base);
|
|
163
|
+
if (path !== null) starts.push({
|
|
164
|
+
path,
|
|
165
|
+
heading: index - 1
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const bodies = /* @__PURE__ */ new Map();
|
|
169
|
+
starts.forEach((start, position) => {
|
|
170
|
+
const end = starts[position + 1]?.heading ?? lines.length;
|
|
171
|
+
bodies.set(start.path, lines.slice(start.heading + 2, end).join("\n").trim());
|
|
172
|
+
});
|
|
173
|
+
return bodies;
|
|
174
|
+
}
|
|
175
|
+
function toEntry(baseUrl, path, title, description, section) {
|
|
176
|
+
const entry = {
|
|
177
|
+
title,
|
|
178
|
+
path,
|
|
179
|
+
url: `${baseUrl}${path === "/" ? "" : path}`,
|
|
180
|
+
markdownUrl: `${baseUrl}${markdownPathFor(path)}`
|
|
181
|
+
};
|
|
182
|
+
if (description !== void 0 && description !== "") entry.description = description;
|
|
183
|
+
if (section !== void 0) entry.section = section;
|
|
184
|
+
return entry;
|
|
185
|
+
}
|
|
186
|
+
/** Blume writes `/docs.md` for `/docs` and `/index.md` for the home page. */
|
|
187
|
+
function markdownPathFor(path) {
|
|
188
|
+
return path === "/" ? "/index.md" : `${path}.md`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Turn whatever an agent passes — a slug, a route, a full URL, with or
|
|
192
|
+
* without `.md` — into a site-relative route.
|
|
193
|
+
*/
|
|
194
|
+
function toRoute(input) {
|
|
195
|
+
let value = input.trim();
|
|
196
|
+
if (/^https?:\/\//u.test(value)) value = new URL(value).pathname;
|
|
197
|
+
value = value.replace(/[?#].*$/u, "").replace(/\.mdx?$/u, "").replace(/\/+$/u, "");
|
|
198
|
+
if (value === "" || value === "/index") return "/";
|
|
199
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
200
|
+
}
|
|
201
|
+
function terms(query) {
|
|
202
|
+
return query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((term) => term.length > 0);
|
|
203
|
+
}
|
|
204
|
+
function countOccurrences(haystack, needle) {
|
|
205
|
+
let count = 0;
|
|
206
|
+
let index = haystack.indexOf(needle);
|
|
207
|
+
while (index !== -1) {
|
|
208
|
+
count++;
|
|
209
|
+
index = haystack.indexOf(needle, index + needle.length);
|
|
210
|
+
}
|
|
211
|
+
return count;
|
|
212
|
+
}
|
|
213
|
+
function excerptOf(body, query, words) {
|
|
214
|
+
const lines = body.split("\n").map((line) => line.trim());
|
|
215
|
+
const lowered = lines.map((line) => line.toLowerCase());
|
|
216
|
+
const phrase = query.trim().toLowerCase();
|
|
217
|
+
const tests = [
|
|
218
|
+
(line) => line.includes(phrase),
|
|
219
|
+
(line) => words.every((word) => line.includes(word)),
|
|
220
|
+
(line) => words.some((word) => line.includes(word))
|
|
221
|
+
];
|
|
222
|
+
for (const test of tests) {
|
|
223
|
+
const index = lowered.findIndex(test);
|
|
224
|
+
const line = lines[index];
|
|
225
|
+
if (index !== -1 && line !== void 0) return line.length > 200 ? `${line.slice(0, 197)}...` : line;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Rank entries against a keyword query. Every term has to appear somewhere —
|
|
230
|
+
* the title, the route, the description, the section or (when given) the
|
|
231
|
+
* body — and where it appears decides the weight.
|
|
232
|
+
*/
|
|
233
|
+
function rankEntries(entries, query, bodies) {
|
|
234
|
+
const words = terms(query);
|
|
235
|
+
if (words.length === 0) return [];
|
|
236
|
+
const phrase = words.join(" ");
|
|
237
|
+
const hits = [];
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const title = entry.title.toLowerCase();
|
|
240
|
+
const titleWords = new Set(terms(entry.title));
|
|
241
|
+
const route = entry.path.toLowerCase();
|
|
242
|
+
const description = (entry.description ?? "").toLowerCase();
|
|
243
|
+
const section = (entry.section ?? "").toLowerCase();
|
|
244
|
+
const body = bodies?.get(entry.path)?.toLowerCase() ?? "";
|
|
245
|
+
let score = title === phrase ? 20 : title.includes(phrase) ? 8 : 0;
|
|
246
|
+
let everyTermFound = true;
|
|
247
|
+
for (const word of words) {
|
|
248
|
+
let weight = 0;
|
|
249
|
+
if (titleWords.has(word)) weight += 10;
|
|
250
|
+
else if (title.includes(word)) weight += 6;
|
|
251
|
+
if (route.includes(word)) weight += 4;
|
|
252
|
+
if (description.includes(word)) weight += 2;
|
|
253
|
+
if (section.includes(word)) weight += 1;
|
|
254
|
+
weight += Math.min(countOccurrences(body, word), 5);
|
|
255
|
+
if (weight === 0) {
|
|
256
|
+
everyTermFound = false;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
score += weight;
|
|
260
|
+
}
|
|
261
|
+
if (!everyTermFound) continue;
|
|
262
|
+
const hit = {
|
|
263
|
+
...entry,
|
|
264
|
+
score
|
|
265
|
+
};
|
|
266
|
+
const excerpt = body === "" ? void 0 : excerptOf(bodies?.get(entry.path) ?? "", query, words);
|
|
267
|
+
if (excerpt !== void 0) hit.excerpt = excerpt;
|
|
268
|
+
hits.push(hit);
|
|
269
|
+
}
|
|
270
|
+
return hits.toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
|
|
271
|
+
}
|
|
272
|
+
var _fetcher = /* @__PURE__ */ new WeakMap();
|
|
273
|
+
var _ttl = /* @__PURE__ */ new WeakMap();
|
|
274
|
+
var _now = /* @__PURE__ */ new WeakMap();
|
|
275
|
+
var _cache = /* @__PURE__ */ new WeakMap();
|
|
276
|
+
var _DocsClient_brand = /* @__PURE__ */ new WeakSet();
|
|
277
|
+
var DocsClient = class {
|
|
278
|
+
constructor(options) {
|
|
279
|
+
_classPrivateMethodInitSpec(this, _DocsClient_brand);
|
|
280
|
+
_defineProperty(this, "baseUrl", void 0);
|
|
281
|
+
_classPrivateFieldInitSpec(this, _fetcher, void 0);
|
|
282
|
+
_classPrivateFieldInitSpec(this, _ttl, void 0);
|
|
283
|
+
_classPrivateFieldInitSpec(this, _now, void 0);
|
|
284
|
+
_classPrivateFieldInitSpec(this, _cache, /* @__PURE__ */ new Map());
|
|
285
|
+
this.baseUrl = normaliseBaseUrl(options.baseUrl);
|
|
286
|
+
_classPrivateFieldSet2(_fetcher, this, options.fetcher ?? ((url) => fetch(url, { signal: AbortSignal.timeout(15e3) })));
|
|
287
|
+
_classPrivateFieldSet2(_ttl, this, options.ttl ?? 3e5);
|
|
288
|
+
_classPrivateFieldSet2(_now, this, options.now ?? Date.now);
|
|
289
|
+
}
|
|
290
|
+
/** Every page the site lists, in the order `llms.txt` lists them. */
|
|
291
|
+
async listDocs() {
|
|
292
|
+
const llms = await _assertClassBrand(_DocsClient_brand, this, _get).call(this, "/llms.txt");
|
|
293
|
+
if (llms !== null) return parseLlmsTxt(llms, this.baseUrl);
|
|
294
|
+
const api = await _assertClassBrand(_DocsClient_brand, this, _get).call(this, "/api/docs/pages.json");
|
|
295
|
+
if (api !== null) return parsePagesApi(api, this.baseUrl);
|
|
296
|
+
throw new DocsRequestError(`${this.baseUrl}/llms.txt`, 404);
|
|
297
|
+
}
|
|
298
|
+
async searchDocs(query, includeContent) {
|
|
299
|
+
const entries = await this.listDocs();
|
|
300
|
+
const bodies = includeContent ? await _assertClassBrand(_DocsClient_brand, this, _bodies).call(this) : null;
|
|
301
|
+
return {
|
|
302
|
+
searchedContent: bodies !== null,
|
|
303
|
+
hits: rankEntries(entries, query, bodies ?? void 0)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* One page's Markdown. A bare slug (`button`) is matched against the index
|
|
308
|
+
* first — the last segment of a route — then tried under `/docs/`.
|
|
309
|
+
*/
|
|
310
|
+
async getDoc(input) {
|
|
311
|
+
const route = toRoute(input);
|
|
312
|
+
const entries = await this.listDocs().catch(() => []);
|
|
313
|
+
const slug = route.slice(1);
|
|
314
|
+
const known = new Set(entries.map((entry) => entry.path));
|
|
315
|
+
const guesses = [
|
|
316
|
+
route,
|
|
317
|
+
`/docs${route}`,
|
|
318
|
+
...entries.filter((entry) => entry.path.endsWith(route)).map((entry) => entry.path)
|
|
319
|
+
];
|
|
320
|
+
const candidates = /* @__PURE__ */ new Set([...guesses.filter((path) => known.has(path)), ...guesses]);
|
|
321
|
+
for (const path of candidates) {
|
|
322
|
+
const markdown = await _assertClassBrand(_DocsClient_brand, this, _get).call(this, markdownPathFor(path));
|
|
323
|
+
if (markdown !== null) return {
|
|
324
|
+
found: true,
|
|
325
|
+
entry: entries.find((candidate) => candidate.path === path) ?? toEntry(this.baseUrl, path, path, void 0, void 0),
|
|
326
|
+
markdown
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
found: false,
|
|
331
|
+
path: route,
|
|
332
|
+
suggestions: rankEntries(entries, terms(slug).map((word) => word.slice(0, Math.max(4, word.length - 2))).join(" ")).slice(0, 5)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* A cached GET. `null` means the site answered 404 — a fact worth caching
|
|
338
|
+
* too; any other failure throws and is not cached, so the next call retries.
|
|
339
|
+
*/
|
|
340
|
+
async function _get(path) {
|
|
341
|
+
const url = `${this.baseUrl}${path}`;
|
|
342
|
+
const cached = _classPrivateFieldGet2(_cache, this).get(url);
|
|
343
|
+
if (cached !== void 0 && cached.expires > _classPrivateFieldGet2(_now, this).call(this)) return cached.value;
|
|
344
|
+
const value = _classPrivateFieldGet2(_fetcher, this).call(this, url).then(async (response) => {
|
|
345
|
+
if (response.status === 404) return null;
|
|
346
|
+
if (!response.ok) throw new DocsRequestError(url, response.status);
|
|
347
|
+
return response.text();
|
|
348
|
+
});
|
|
349
|
+
if (_classPrivateFieldGet2(_ttl, this) > 0) {
|
|
350
|
+
_classPrivateFieldGet2(_cache, this).set(url, {
|
|
351
|
+
expires: _classPrivateFieldGet2(_now, this).call(this) + _classPrivateFieldGet2(_ttl, this),
|
|
352
|
+
value
|
|
353
|
+
});
|
|
354
|
+
value.catch(() => _classPrivateFieldGet2(_cache, this).delete(url));
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
/** Page bodies keyed by route, or `null` when the site has no `llms-full.txt`. */
|
|
359
|
+
async function _bodies() {
|
|
360
|
+
const full = await _assertClassBrand(_DocsClient_brand, this, _get).call(this, "/llms-full.txt");
|
|
361
|
+
return full === null ? null : parseLlmsFull(full, this.baseUrl);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/server.ts
|
|
366
|
+
const DEFAULT_LIMIT = 10;
|
|
367
|
+
const MAX_LIMIT = 50;
|
|
368
|
+
/** A tool answering in prose or Markdown. */
|
|
369
|
+
function text(message) {
|
|
370
|
+
return { content: [{
|
|
371
|
+
type: "text",
|
|
372
|
+
text: message
|
|
373
|
+
}] };
|
|
374
|
+
}
|
|
375
|
+
/** A tool reporting a failure the agent should read rather than retry blindly. */
|
|
376
|
+
function failure(message) {
|
|
377
|
+
return {
|
|
378
|
+
...text(message),
|
|
379
|
+
isError: true
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function asJson(value) {
|
|
383
|
+
return JSON.stringify(value, null, 2);
|
|
384
|
+
}
|
|
385
|
+
function createServer(docs) {
|
|
386
|
+
const server = new McpServer({
|
|
387
|
+
name: "newtui-docs",
|
|
388
|
+
version
|
|
389
|
+
});
|
|
390
|
+
server.registerTool("list_docs", {
|
|
391
|
+
title: "List documentation pages",
|
|
392
|
+
description: "Every page of the newt/ui documentation (Discord-inspired components for React, Vue and HTML/CSS) with its title, route, description and section. Use it to find the page to read with get_doc.",
|
|
393
|
+
inputSchema: { section: z.string().optional().describe("Only pages listed under this section, e.g. 'Installation' or 'Forms'. Case-insensitive.") },
|
|
394
|
+
annotations: {
|
|
395
|
+
readOnlyHint: true,
|
|
396
|
+
openWorldHint: true
|
|
397
|
+
}
|
|
398
|
+
}, async ({ section }) => {
|
|
399
|
+
const all = await docs.listDocs();
|
|
400
|
+
const pages = section === void 0 ? all : all.filter((page) => page.section?.toLowerCase() === section.toLowerCase());
|
|
401
|
+
return text(asJson({
|
|
402
|
+
site: docs.baseUrl,
|
|
403
|
+
count: pages.length,
|
|
404
|
+
pages
|
|
405
|
+
}));
|
|
406
|
+
});
|
|
407
|
+
server.registerTool("search_docs", {
|
|
408
|
+
title: "Search the documentation",
|
|
409
|
+
description: "Keyword search over the newt/ui docs. Ranks pages by where every query term appears: title first, then route, description and section. Set content to true to search page bodies as well — slower, but it finds a prop or a token mentioned only inside a page.",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
query: z.string().min(1).describe("Keywords, e.g. 'dark mode' or 'nuxt install'."),
|
|
412
|
+
content: z.boolean().optional().describe("Also search the full text of every page."),
|
|
413
|
+
limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe(`How many results to return. Defaults to ${DEFAULT_LIMIT}.`)
|
|
414
|
+
},
|
|
415
|
+
annotations: {
|
|
416
|
+
readOnlyHint: true,
|
|
417
|
+
openWorldHint: true
|
|
418
|
+
}
|
|
419
|
+
}, async ({ query, content, limit }) => {
|
|
420
|
+
const result = await docs.searchDocs(query, content ?? false);
|
|
421
|
+
const results = result.hits.slice(0, limit ?? DEFAULT_LIMIT);
|
|
422
|
+
return text(asJson({
|
|
423
|
+
site: docs.baseUrl,
|
|
424
|
+
query,
|
|
425
|
+
searchedContent: result.searchedContent,
|
|
426
|
+
count: results.length,
|
|
427
|
+
results
|
|
428
|
+
}));
|
|
429
|
+
});
|
|
430
|
+
server.registerTool("get_doc", {
|
|
431
|
+
title: "Read a documentation page",
|
|
432
|
+
description: "One page of the newt/ui docs as Markdown. Accepts a route ('/docs/theming'), a slug ('button', 'installation/nuxt') or a full newtui.dev URL. Read this rather than recalling an API from memory.",
|
|
433
|
+
inputSchema: { path: z.string().min(1).describe("The page: a route, a slug or a URL.") },
|
|
434
|
+
annotations: {
|
|
435
|
+
readOnlyHint: true,
|
|
436
|
+
openWorldHint: true
|
|
437
|
+
}
|
|
438
|
+
}, async ({ path }) => {
|
|
439
|
+
const page = await docs.getDoc(path);
|
|
440
|
+
if (!page.found) return failure(`No documentation page at "${page.path}". Closest matches:\n\n${asJson({
|
|
441
|
+
site: docs.baseUrl,
|
|
442
|
+
path: page.path,
|
|
443
|
+
suggestions: page.suggestions
|
|
444
|
+
})}`);
|
|
445
|
+
return text(`<!-- Source: ${page.entry.url} -->\n\n${page.markdown}`);
|
|
446
|
+
});
|
|
447
|
+
return server;
|
|
448
|
+
}
|
|
449
|
+
/** Serve over stdio, which is how every MCP client launches a local server. */
|
|
450
|
+
async function startServer(docs) {
|
|
451
|
+
await createServer(docs).connect(new StdioServerTransport());
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/index.ts
|
|
456
|
+
const HELP = `newtui-mcp — an MCP server for the newt/ui documentation
|
|
457
|
+
|
|
458
|
+
Usage
|
|
459
|
+
$ npx @newtui/mcp [--url <site>]
|
|
460
|
+
|
|
461
|
+
Options
|
|
462
|
+
--url <site> the docs site to read (default: ${DEFAULT_DOCS_URL})
|
|
463
|
+
also read from NEWTUI_DOCS_URL
|
|
464
|
+
-h, --help show this help
|
|
465
|
+
-v, --version print the version
|
|
466
|
+
|
|
467
|
+
The server speaks MCP over stdio; an editor launches it, not a person.`;
|
|
468
|
+
async function main() {
|
|
469
|
+
const { values } = parseArgs({ options: {
|
|
470
|
+
url: { type: "string" },
|
|
471
|
+
help: {
|
|
472
|
+
type: "boolean",
|
|
473
|
+
short: "h"
|
|
474
|
+
},
|
|
475
|
+
version: {
|
|
476
|
+
type: "boolean",
|
|
477
|
+
short: "v"
|
|
478
|
+
}
|
|
479
|
+
} });
|
|
480
|
+
if (values.help === true) {
|
|
481
|
+
console.log(HELP);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (values.version === true) {
|
|
485
|
+
console.log(version);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const baseUrl = resolveDocsUrl(values.url, process.env.NEWTUI_DOCS_URL);
|
|
489
|
+
let docs;
|
|
490
|
+
try {
|
|
491
|
+
docs = new DocsClient({ baseUrl });
|
|
492
|
+
} catch {
|
|
493
|
+
console.error(`newtui-mcp: "${baseUrl}" is not a URL.`);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
await startServer(docs);
|
|
497
|
+
}
|
|
498
|
+
main().catch((error) => {
|
|
499
|
+
console.error(`newtui-mcp: ${error instanceof Error ? error.message : String(error)}`);
|
|
500
|
+
process.exit(1);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
//#endregion
|
|
504
|
+
export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newtui/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for the newt/ui documentation — lets a coding agent list, search and read the Discord-inspired component library's docs. Part of the WolfStar toolchain.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"component-library",
|
|
7
|
+
"design-system",
|
|
8
|
+
"discord",
|
|
9
|
+
"documentation",
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol",
|
|
12
|
+
"ui",
|
|
13
|
+
"wolfstar"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://newtui.dev/docs/mcp",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/wolfstar-project/newt-ui/issues"
|
|
18
|
+
},
|
|
19
|
+
"license": "Apache-2.0",
|
|
20
|
+
"author": "@wolfstar",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/wolfstar-project/newt-ui.git",
|
|
24
|
+
"directory": "packages/mcp"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"newtui-mcp": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": true
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsdown --config-loader unrun",
|
|
41
|
+
"dev": "tsdown --config-loader unrun --watch",
|
|
42
|
+
"prepack": "pnpm run build",
|
|
43
|
+
"start": "node dist/index.js",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
50
|
+
"zod": "^3.24.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.10.2",
|
|
54
|
+
"tsdown": "^0.22.14",
|
|
55
|
+
"typescript": "^5.7.2",
|
|
56
|
+
"unrun": "^0.3.1",
|
|
57
|
+
"vitest": "^5.0.4"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
}
|
|
62
|
+
}
|