@quasar/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/src/server.js ADDED
@@ -0,0 +1,526 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { relative } from 'node:path'
3
+ import { z } from 'zod'
4
+
5
+ import {
6
+ API_PARTS,
7
+ MEMBER_PARTS,
8
+ findApiMembers,
9
+ listApi,
10
+ readApi,
11
+ readApiMarkdown,
12
+ readApiMembersMarkdown,
13
+ resolveApiName,
14
+ similarApiMembers,
15
+ similarApiNames
16
+ } from './api.js'
17
+ import {
18
+ DOCS_FORMAT,
19
+ extractSection,
20
+ listHeadings,
21
+ loadDocs,
22
+ normalizeRoute,
23
+ pageSize,
24
+ readPage,
25
+ routeFragment,
26
+ searchDocs,
27
+ similarRoutes
28
+ } from './docs.js'
29
+ import { BUNDLED_DOCS_SINCE, DOCS_PACKAGES } from './project.js'
30
+ import { checkUpdates as defaultCheckUpdates } from './updates.js'
31
+ import { version } from './version.js'
32
+
33
+ const SITE_URL = 'https://quasar.dev'
34
+
35
+ /**
36
+ * @param {string} text
37
+ * @returns {{ content: Array<{ type: 'text', text: string }> }}
38
+ */
39
+ function text(value) {
40
+ return { content: [{ type: 'text', text: value }] }
41
+ }
42
+
43
+ /**
44
+ * @param {string} message
45
+ * @returns {{ content: Array<{ type: 'text', text: string }>, isError: true }}
46
+ */
47
+ function failure(message) {
48
+ return { content: [{ type: 'text', text: message }], isError: true }
49
+ }
50
+
51
+ /**
52
+ * @param {import('./updates.js').UpdateState[]} updates
53
+ * @returns {string[]}
54
+ */
55
+ function updateLines(updates) {
56
+ return updates
57
+ .filter(update => update.latest !== void 0)
58
+ .map(
59
+ update =>
60
+ `${update.name} ${update.latest} is available (installed: ${update.version}).`
61
+ )
62
+ }
63
+
64
+ /**
65
+ * What the model reads at session start: which packages and versions
66
+ * the answers describe, what is missing, and how to use the tools.
67
+ *
68
+ * @param {import('./project.js').Project} project
69
+ * @param {import('./docs.js').Docs} docs
70
+ * @param {import('./updates.js').UpdateState[]} updates
71
+ * @returns {string}
72
+ */
73
+ /**
74
+ * The packages whose pages this server cannot serve, and why, for a
75
+ * miss that may be one of theirs. Empty when every package is served.
76
+ *
77
+ * @param {import('./project.js').Project} project
78
+ * @param {import('./docs.js').Docs} docs
79
+ * @returns {string}
80
+ */
81
+ function unservedPackages(project, docs) {
82
+ const gaps = []
83
+ for (const name of DOCS_PACKAGES) {
84
+ if (docs.sources.some(source => source.name === name)) {
85
+ continue
86
+ }
87
+ const pkg = project.packages.find(installed => installed.name === name)
88
+ const other = docs.unreadable.find(slice => slice.name === name)
89
+ gaps.push(
90
+ pkg === void 0
91
+ ? `${name} is not installed in this project`
92
+ : other !== void 0
93
+ ? otherFormat(other)
94
+ : `${name} ${pkg.version} bundles no documentation (bundled since ${BUNDLED_DOCS_SINCE[name]}), upgrade it`
95
+ )
96
+ }
97
+ return gaps.length === 0 ? '' : ` Not available offline: ${gaps.join('; ')}.`
98
+ }
99
+
100
+ /**
101
+ * @param {{ name: string, version: string, format: number }} slice
102
+ * @returns {string}
103
+ */
104
+ function otherFormat(slice) {
105
+ return (
106
+ `${slice.name} ${slice.version} bundles its documentation in format ${slice.format}, this server reads format ${DOCS_FORMAT}; ` +
107
+ `run \`npx -y --fetch-retries=0 @quasar/mcp@${slice.format}\` instead (the server's major version tracks the format)`
108
+ )
109
+ }
110
+
111
+ /** How many of the other apps of a workspace the instructions name. */
112
+ const OTHER_APPS_SHOWN = 5
113
+
114
+ export function buildInstructions(project, docs, updates) {
115
+ const lines = [
116
+ project.dir === project.startDir
117
+ ? `Quasar Framework documentation and API, served from the packages installed in ${project.dir}.`
118
+ : `Quasar Framework documentation and API, served from the packages installed in ${relative(project.startDir, project.dir)}, the Quasar app found below ${project.startDir}.`,
119
+ 'Prefer these tools over memory or the web: the pages match the installed versions exactly.',
120
+ ''
121
+ ]
122
+ if (project.otherApps.length !== 0) {
123
+ const shown = project.otherApps
124
+ .slice(0, OTHER_APPS_SHOWN)
125
+ .map(app => relative(project.startDir, app))
126
+ const more = project.otherApps.length - shown.length
127
+ lines.push(
128
+ `Other Quasar apps in this workspace, not served: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}. To serve one of them, start the server with --project <dir>.`,
129
+ ''
130
+ )
131
+ }
132
+
133
+ for (const name of DOCS_PACKAGES) {
134
+ const pkg = project.packages.find(installed => installed.name === name)
135
+ const other = docs.unreadable.find(slice => slice.name === name)
136
+ if (pkg === void 0) {
137
+ lines.push(`- ${name}: not installed in this project.`)
138
+ } else if (other !== void 0) {
139
+ lines.push(`- ${otherFormat(other)}.`)
140
+ } else if (pkg.docsDir === null) {
141
+ lines.push(
142
+ `- ${name} ${pkg.version}: installed, but this release bundles no documentation ` +
143
+ `(bundled since ${BUNDLED_DOCS_SINCE[name]}); suggest upgrading it to get the docs pages offline.`
144
+ )
145
+ } else {
146
+ const source = docs.sources.find(entry => entry.name === name)
147
+ lines.push(
148
+ `- ${name} ${pkg.version}: ${source?.pageCount ?? 0} documentation pages.`
149
+ )
150
+ }
151
+ }
152
+
153
+ const quasar = project.packages.find(pkg => pkg.name === 'quasar')
154
+ if (quasar?.apiDir) {
155
+ lines.push(
156
+ `- quasar ${quasar.version} API descriptors (components, plugins, directives, composables): get_api / list_api.`
157
+ )
158
+ }
159
+
160
+ lines.push(
161
+ '',
162
+ 'Workflow: search_docs to find pages (each hit names the size of the page and the sections where the terms occur), get_page with section to read just that part (outline lists the sections; a whole component page can run to 25k tokens), get_api for the exact props, slots, events and methods of a component, plugin or directive.',
163
+ `Pages are routes of ${SITE_URL} (e.g. vue-components/button). Links inside pages are either .md siblings relative to the page's route or ${SITE_URL} URLs: get_page takes both as they appear.`
164
+ )
165
+
166
+ const available = updateLines(updates)
167
+ if (available.length !== 0) {
168
+ lines.push(
169
+ '',
170
+ 'Updates available, tell the user (they upgrade with their package manager; for @quasar/mcp restart the server):',
171
+ ...available.map(line => `- ${line}`)
172
+ )
173
+ }
174
+
175
+ return lines.join('\n')
176
+ }
177
+
178
+ /**
179
+ * @param {{ project: import('./project.js').Project, checkUpdates?: typeof defaultCheckUpdates }} opts
180
+ * @returns {Promise<McpServer>}
181
+ */
182
+ export async function createServer({
183
+ project,
184
+ checkUpdates = defaultCheckUpdates
185
+ }) {
186
+ const docs = loadDocs(project.packages)
187
+ const quasar = project.packages.find(pkg => pkg.name === 'quasar')
188
+ const apiDir = quasar?.apiDir ?? null
189
+ // the rendered API files come with the slice, and only a slice of
190
+ // this server's format is parsed
191
+ const apiDocsDir = docs.sources.some(source => source.name === 'quasar')
192
+ ? quasar.docsDir
193
+ : null
194
+ const updates = await checkUpdates(project)
195
+
196
+ const server = new McpServer(
197
+ { name: 'quasar', version },
198
+ { instructions: buildInstructions(project, docs, updates) }
199
+ )
200
+
201
+ const packageEnum = z.enum(DOCS_PACKAGES)
202
+ // nothing here writes; only check_updates leaves the machine
203
+ const localRead = { readOnlyHint: true, openWorldHint: false }
204
+ const remoteRead = { readOnlyHint: true, openWorldHint: true }
205
+
206
+ server.registerTool(
207
+ 'list_pages',
208
+ {
209
+ title: 'List documentation pages',
210
+ description:
211
+ 'Every documentation page available offline, as route, title and approximate size, grouped by the installed package that ships it. Prefer search_docs to find a page; this is the full index.',
212
+ annotations: localRead,
213
+ inputSchema: {
214
+ package: packageEnum
215
+ .optional()
216
+ .describe('Only the pages shipped by this package'),
217
+ descriptions: z
218
+ .boolean()
219
+ .optional()
220
+ .describe(
221
+ "Add each page's one-line description (doubles the size of the listing)"
222
+ )
223
+ }
224
+ },
225
+ ({ package: packageName, descriptions = false }) => {
226
+ const lines = []
227
+ for (const source of docs.sources) {
228
+ if (packageName !== void 0 && source.name !== packageName) {
229
+ continue
230
+ }
231
+ lines.push(`# ${source.name} ${source.version}`, '')
232
+ for (const page of docs.pages.values()) {
233
+ if (page.packageName === source.name) {
234
+ lines.push(
235
+ `- ${page.route}: ${page.title} [${pageSize(page)}]${descriptions && page.desc ? ` (${page.desc})` : ''}`
236
+ )
237
+ }
238
+ }
239
+ lines.push('')
240
+ }
241
+ if (lines.length === 0) {
242
+ return failure(
243
+ 'No documentation pages are installed. ' +
244
+ `The pages ship with quasar (since ${BUNDLED_DOCS_SINCE.quasar}) and @quasar/app-vite (since ${BUNDLED_DOCS_SINCE['@quasar/app-vite']}).`
245
+ )
246
+ }
247
+ return text(lines.join('\n').trim())
248
+ }
249
+ )
250
+
251
+ server.registerTool(
252
+ 'search_docs',
253
+ {
254
+ title: 'Search the documentation',
255
+ description:
256
+ "Find documentation pages by keywords (component names, props, features, config options). Each hit names the approximate size of the page and the sections where the keywords occur: pass one as get_page's section to read only that part.",
257
+ annotations: localRead,
258
+ inputSchema: {
259
+ query: z
260
+ .string()
261
+ .min(1)
262
+ .describe('Keywords, e.g. "table pagination" or "boot files"'),
263
+ package: packageEnum
264
+ .optional()
265
+ .describe("Only search this package's pages"),
266
+ limit: z
267
+ .number()
268
+ .int()
269
+ .min(1)
270
+ .max(50)
271
+ .optional()
272
+ .describe('Maximum hits (default 5)')
273
+ }
274
+ },
275
+ ({ query, package: packageName, limit }) => {
276
+ const hits = searchDocs(docs, query, { limit, packageName })
277
+ if (hits.length === 0) {
278
+ return text(
279
+ docs.pages.size === 0
280
+ ? 'No documentation pages are installed, nothing to search.'
281
+ : `No page matches "${query}". Try fewer or different keywords, or list_pages.`
282
+ )
283
+ }
284
+ return text(
285
+ hits
286
+ .map(({ page, sections }) => {
287
+ const lines = [`- ${page.route}: ${page.title} [${pageSize(page)}]`]
288
+ if (page.desc) lines.push(` ${page.desc}`)
289
+ if (sections.length !== 0) {
290
+ lines.push(` sections: ${sections.join(' | ')}`)
291
+ }
292
+ return lines.join('\n')
293
+ })
294
+ .join('\n')
295
+ )
296
+ }
297
+ )
298
+
299
+ server.registerTool(
300
+ 'get_page',
301
+ {
302
+ title: 'Read a documentation page',
303
+ description:
304
+ 'The markdown of one documentation page, by route (as listed by list_pages or search_docs, a link from another page, or a quasar.dev URL; a #fragment selects that section). Pass a heading to get only that section, or outline to get the headings and pick one: whole component pages are long.',
305
+ annotations: localRead,
306
+ inputSchema: {
307
+ route: z
308
+ .string()
309
+ .min(1)
310
+ .describe('Page route, e.g. vue-components/button'),
311
+ section: z
312
+ .string()
313
+ .optional()
314
+ .describe(
315
+ 'A heading of the page, to return only that section, its subsections included (case does not matter, the #anchor form works too); omit for the whole page'
316
+ ),
317
+ outline: z
318
+ .boolean()
319
+ .optional()
320
+ .describe(
321
+ 'Only the title and headings of the page, to pick a section'
322
+ )
323
+ }
324
+ },
325
+ ({ route: input, section, outline = false }) => {
326
+ const route = normalizeRoute(input)
327
+ const page = docs.pages.get(route)
328
+ if (page === void 0) {
329
+ const similar = similarRoutes(docs, route)
330
+ return failure(
331
+ `No page at "${route}".` +
332
+ (similar.length !== 0
333
+ ? ` Similar routes: ${similar.join(', ')}.`
334
+ : ' Use search_docs or list_pages to find the route.') +
335
+ unservedPackages(project, docs)
336
+ )
337
+ }
338
+ const markdown = readPage(page)
339
+ if (outline) {
340
+ return text(
341
+ [
342
+ `# ${page.title}`,
343
+ ...listHeadings(markdown).map(
344
+ heading => `${'#'.repeat(heading.level)} ${heading.text}`
345
+ )
346
+ ].join('\n')
347
+ )
348
+ }
349
+ if (section === void 0) {
350
+ // a pasted link points at a heading, or at an anchor no heading
351
+ // carries (an example, an API card): then the page it is
352
+ const fragment = routeFragment(input)
353
+ return text(
354
+ (fragment !== null ? extractSection(markdown, fragment) : null) ??
355
+ markdown
356
+ )
357
+ }
358
+ const extracted = extractSection(markdown, section)
359
+ if (extracted === null) {
360
+ return failure(
361
+ `No section "${section}" in ${route}. Its headings: ${listHeadings(
362
+ markdown
363
+ )
364
+ .map(heading => heading.text)
365
+ .join(' | ')}`
366
+ )
367
+ }
368
+ return text(extracted)
369
+ }
370
+ )
371
+
372
+ server.registerTool(
373
+ 'list_api',
374
+ {
375
+ title: 'List API descriptors',
376
+ description:
377
+ 'The names every get_api call accepts: Quasar components (QBtn, QTable, ...), plugins (Notify, Dialog, ...), directives (Ripple, ...) and utilities.',
378
+ annotations: localRead,
379
+ inputSchema: {}
380
+ },
381
+ () => {
382
+ if (apiDir === null) {
383
+ return failure(
384
+ 'quasar is not installed in this project, so there is no API to list.'
385
+ )
386
+ }
387
+ return text(listApi(apiDir).join('\n'))
388
+ }
389
+ )
390
+
391
+ server.registerTool(
392
+ 'get_api',
393
+ {
394
+ title: 'Get an API descriptor',
395
+ description:
396
+ 'The exact API of a Quasar component, plugin or directive as installed: props, slots, events, methods (with types, defaults and descriptions), as the documentation site presents it. Pass part for one section, member for one prop, slot, event or method (the cheapest call by far), format "json" for the raw descriptor.',
397
+ annotations: localRead,
398
+ inputSchema: {
399
+ name: z
400
+ .string()
401
+ .min(1)
402
+ .describe('Descriptor name, e.g. QBtn, Notify, Ripple'),
403
+ part: z
404
+ .enum(API_PARTS)
405
+ .optional()
406
+ .describe('One section of the descriptor; omit for all of it'),
407
+ member: z
408
+ .string()
409
+ .optional()
410
+ .describe(
411
+ 'One prop, slot, event or method by name (pagination, body-cell, update:model-value, toggleFullscreen; case and punctuation do not matter); with part when the name exists in several'
412
+ ),
413
+ format: z
414
+ .enum(['markdown', 'json'])
415
+ .optional()
416
+ .describe(
417
+ 'markdown (default): the compact form the documentation site inlines; json: the raw descriptor'
418
+ )
419
+ }
420
+ },
421
+ ({ name: input, part, member, format = 'markdown' }) => {
422
+ if (apiDir === null) {
423
+ return failure(
424
+ 'quasar is not installed in this project, so there is no API to serve.'
425
+ )
426
+ }
427
+ const name = resolveApiName(apiDir, input)
428
+ if (name === null) {
429
+ const similar = similarApiNames(apiDir, input)
430
+ return failure(
431
+ `No API descriptor named "${input}".` +
432
+ (similar.length !== 0
433
+ ? ` Similar names: ${similar.join(', ')}.`
434
+ : ' Use list_api for the available names.')
435
+ )
436
+ }
437
+ const api = readApi(apiDir, name)
438
+ if (api === null) {
439
+ return failure(`The ${name} descriptor could not be read.`)
440
+ }
441
+ if (part !== void 0 && api[part] === void 0) {
442
+ const parts = API_PARTS.filter(known => api[known] !== void 0)
443
+ return failure(`${name} has no "${part}". It has: ${parts.join(', ')}.`)
444
+ }
445
+ if (member !== void 0) {
446
+ if (part !== void 0 && !MEMBER_PARTS.includes(part)) {
447
+ return failure(
448
+ `"${part}" has no named members; member goes with ${MEMBER_PARTS.join(', ')}.`
449
+ )
450
+ }
451
+ const members = findApiMembers(api, member, part)
452
+ if (members.length === 0) {
453
+ const similar = similarApiMembers(api, member, part)
454
+ return failure(
455
+ `${name} has no member named "${member}"${part === void 0 ? '' : ` in ${part}`}.` +
456
+ (similar.length !== 0
457
+ ? ` Similar: ${similar.join(', ')}.`
458
+ : ' Pass part for the full list of a section.')
459
+ )
460
+ }
461
+ const markdown =
462
+ format === 'markdown' && apiDocsDir !== null
463
+ ? readApiMembersMarkdown(apiDocsDir, name, members)
464
+ : null
465
+ if (markdown !== null) {
466
+ return text(markdown)
467
+ }
468
+ const picked = { name }
469
+ for (const found of members) {
470
+ picked[found.part] ??= {}
471
+ picked[found.part][found.name] = api[found.part][found.name]
472
+ }
473
+ return text(JSON.stringify(picked, null, 1))
474
+ }
475
+ // The rendered form ships with the docs slice (quasar v2.33+); a
476
+ // release without it, or a part the renderer leaves out when
477
+ // empty, gets the JSON.
478
+ const markdown =
479
+ format === 'markdown' && apiDocsDir !== null
480
+ ? readApiMarkdown(apiDocsDir, name, part)
481
+ : null
482
+ if (markdown !== null) {
483
+ return text(markdown)
484
+ }
485
+ return text(
486
+ JSON.stringify(
487
+ part === void 0 ? { name, ...api } : { name, [part]: api[part] },
488
+ null,
489
+ 1
490
+ )
491
+ )
492
+ }
493
+ )
494
+
495
+ server.registerTool(
496
+ 'check_updates',
497
+ {
498
+ title: 'Check for updates',
499
+ description:
500
+ 'Whether newer releases of quasar, @quasar/app-vite or this server exist, by querying the npm registry. Newer docs come with the newer packages.',
501
+ annotations: remoteRead,
502
+ inputSchema: {}
503
+ },
504
+ async () => {
505
+ const state = await checkUpdates(project, { refresh: true })
506
+ const available = updateLines(state)
507
+ const installed = state
508
+ .map(entry => `${entry.name} ${entry.version}`)
509
+ .join(', ')
510
+ if (available.length === 0) {
511
+ return text(
512
+ `Up to date (or the registry could not be reached): ${installed}.`
513
+ )
514
+ }
515
+ return text(
516
+ [
517
+ ...available,
518
+ '',
519
+ "Upgrade the packages with the project's package manager; @quasar/mcp picks up its new release on the next server start (the documented npx form does that by itself)."
520
+ ].join('\n')
521
+ )
522
+ }
523
+ )
524
+
525
+ return server
526
+ }
package/src/slugify.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Turns a heading into the id it is found by: markup dropped, `&` read as
3
+ * "and", every other run of non-alphanumerics one dash, none at either
4
+ * end. The site renders its heading ids with it, so a quasar.dev fragment
5
+ * names the same section here.
6
+ *
7
+ * MIRRORED in docs/build/utils.js, which cannot be imported from a
8
+ * published package: any change here is made there too, and
9
+ * docs/build/utils.test.js fails while the two differ.
10
+ *
11
+ * @param {string} str
12
+ * @returns {string}
13
+ */
14
+ export function slugify(str) {
15
+ return String(str)
16
+ .replaceAll(/<\/?[^>]+(>|$)/g, '')
17
+ .toLowerCase()
18
+ .replaceAll('&', ' and ')
19
+ .replaceAll(/[^a-z0-9]+/g, '-')
20
+ .replaceAll(/^-+|-+$/g, '')
21
+ }
package/src/updates.js ADDED
@@ -0,0 +1,41 @@
1
+ import { getAvailableUpdate } from '@quasar/update-notifier'
2
+
3
+ import { version } from './version.js'
4
+
5
+ const REFRESH_TIMEOUT = 10_000
6
+
7
+ /**
8
+ * @typedef {object} UpdateState
9
+ * @property {string} name
10
+ * @property {string} version
11
+ * @property {string | undefined} latest A newer release, when one is known.
12
+ */
13
+
14
+ /**
15
+ * Newer releases of the server and of the installed docs packages, via
16
+ * the notifier shared with the Quasar CLIs: from its cache (refreshed in
17
+ * the background, so a session usually learns about a release the day
18
+ * after it ships), or straight from the registry with `refresh`. An
19
+ * offline machine gets no check at all.
20
+ *
21
+ * @param {import('./project.js').Project} project
22
+ * @param {{ refresh?: boolean }} [opts]
23
+ * @returns {Promise<UpdateState[]>}
24
+ */
25
+ export function checkUpdates(project, { refresh = false } = {}) {
26
+ const targets = [
27
+ { name: '@quasar/mcp', version },
28
+ ...project.packages.map(pkg => ({ name: pkg.name, version: pkg.version }))
29
+ ]
30
+ return Promise.all(
31
+ targets.map(async target => ({
32
+ ...target,
33
+ latest: await getAvailableUpdate({
34
+ ...target,
35
+ refresh,
36
+ // a tool call is waited on; the background check keeps the default
37
+ timeout: REFRESH_TIMEOUT
38
+ })
39
+ }))
40
+ )
41
+ }
package/src/version.js ADDED
@@ -0,0 +1,6 @@
1
+ import { createRequire } from 'node:module'
2
+
3
+ const require = createRequire(import.meta.url)
4
+
5
+ /** @type {string} */
6
+ export const { version } = require('../package.json')