@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.
Files changed (3) hide show
  1. package/README.md +60 -2
  2. package/dist/index.js +504 -0
  3. package/package.json +60 -4
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @newtui/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }