Skip to content

Commit f52dc38

Browse files
authored
feat(mcp): add MCP server tools (list-pages, get-page) (#33)
* feat(mcp): add MCP server tools (list-pages, get-page) Port @nuxtjs/mcp-toolkit MCP server tools from upstream docus so MCP clients can query documentation pages directly. Tools added (auto-discovered from `server/mcp/tools/*.ts`): - `list-pages`: lists all pages with title, path, description, locale, and absolute URL. Optional `locale` filter. Skips `.navigation` paths. - `get-page`: returns a single page by exact path, with the full markdown body fetched from the existing `/raw/{path}.md` route. Skips `.navigation` paths. URL derivation prefers the live request origin (via `getRequestURL`), then falls back to `inferSiteURL()`. `app.baseURL` is joined into the URL via `ufo.joinURL` so prefixed deployments stay correct. New shared helpers in `server/utils/content.ts`: `getAvailableLocales`, `getCollectionsToQuery`, `isNavigationPath`, and `getCollectionFromPath`. The existing `server/routes/sitemap.xml.ts` now reuses `getAvailableLocales` and `isNavigationPath` from there (behavior unchanged). Also: - Add `@nuxtjs/mcp-toolkit` (^0.17.2) to layer dependencies and register it in `nuxt.config.ts` modules. - Tools are auto-imported via the toolkit's `defineMcpTool`, `useEvent`, `useRuntimeConfig`, `createError`, `getRequestURL`. - Each tool caches its response for `'1h'`. Tolerate per-collection query failures in `list-pages` (missing i18n locale collections, etc.) so a single bad collection doesn't fail the whole listing. Verified: `bun lint` passes, `bun typecheck` introduces no new errors, `bun --filter @pleaseai/docs-site build` succeeds with the `node-server` preset, and `bun dev` starts cleanly with `[@nuxtjs/mcp-toolkit] /mcp enabled with 2 tools`. Note: under the Cloudflare Pages preset (the docs site's default preset), the toolkit's Cloudflare transport dynamically imports `agents/mcp`, which lazy-loads `ai`. Both are optional peers of the toolkit, but `noExternals: true` on the Cloudflare preset breaks the dynamic import chain at build time. Working through this is tracked separately — the dev server and node-server build are unaffected. Refs upstream docus commits: - ec3b468c (initial) - 3eb54da4 (exclude .navigation) - f7e42ebf (baseURL fix) - 6043beb1 (derive URLs from request origin) Survey item docs/docus-upstream-changes.md#8. * fix(mcp): apply review suggestions - content.ts: return [] when locale is given but unsupported on i18n sites, so list-pages no longer silently falls back to every other language. Single-locale (non-i18n) sites still return ['docs']. - get-page.ts: build the /raw<path>.md fetch URL from the canonical page.path rather than the raw user input, so trailing slashes or non-canonical casing don't produce a 404. - get-page.ts: guard error type before reading .statusCode so a primitive thrown value cannot trigger a TypeError. Addresses gemini-code-assist and cubic-dev-ai review threads on #33.
1 parent 903dcfc commit f52dc38

7 files changed

Lines changed: 615 additions & 35 deletions

File tree

‎bun.lock‎

Lines changed: 376 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/layer/nuxt.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default defineNuxtConfig({
1414
'@nuxt/image',
1515
'@nuxt/icon',
1616
'nuxt-og-image',
17+
'@nuxtjs/mcp-toolkit',
1718
'@nuxtjs/robots',
1819
'nuxt-llms',
1920
() => {

‎packages/layer/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@nuxt/image": "^2.0.0",
4242
"@nuxt/kit": "^4.2.2",
4343
"@nuxtjs/color-mode": "^4.0.0",
44+
"@nuxtjs/mcp-toolkit": "^0.17.2",
4445
"@nuxtjs/mdc": "^0.19.1",
4546
"@nuxtjs/robots": "^5.6.7",
4647
"@tabler/icons-vue": "^3.35.0",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Collections } from '@nuxt/content'
2+
import { queryCollection } from '@nuxt/content/server'
3+
import { joinURL, withLeadingSlash } from 'ufo'
4+
import { z } from 'zod'
5+
import { inferSiteURL } from '../../../utils/meta'
6+
import { getAvailableLocales, getCollectionFromPath, isNavigationPath } from '../../utils/content'
7+
8+
export default defineMcpTool({
9+
description: `Retrieves the full content and details of a specific documentation page.
10+
11+
WHEN TO USE: Use this tool when you know the EXACT path to a documentation page. Common use cases:
12+
- User asks for a specific page: "Show me the getting started guide" -> /en/getting-started/installation
13+
- User asks about a known topic with a dedicated page
14+
- You found a relevant path from list-pages and want the full content
15+
- User references a specific section or guide they want to read
16+
17+
WHEN NOT TO USE: If you don't know the exact path and need to search/explore, use list-pages first.
18+
19+
WORKFLOW: This tool returns the complete page content including title, description, and full markdown. Use this when you need to provide detailed answers or code examples from specific documentation pages.
20+
`,
21+
annotations: {
22+
readOnlyHint: true,
23+
destructiveHint: false,
24+
idempotentHint: true,
25+
openWorldHint: false,
26+
},
27+
inputSchema: {
28+
path: z.string().describe(
29+
'The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)',
30+
),
31+
},
32+
inputExamples: [
33+
{ path: '/en/getting-started/installation' },
34+
{ path: '/getting-started/introduction' },
35+
],
36+
cache: '1h',
37+
handler: async ({ path }) => {
38+
if (isNavigationPath(path))
39+
throw createError({ statusCode: 404, message: 'Page not found' })
40+
41+
const event = useEvent()
42+
const config = useRuntimeConfig(event)
43+
const publicConfig = config.public as Record<string, unknown>
44+
const siteUrl = getRequestURL(event).origin || inferSiteURL()
45+
const baseURL = config.app?.baseURL || '/'
46+
47+
const availableLocales = getAvailableLocales(publicConfig)
48+
const collectionName = availableLocales.length > 0
49+
? getCollectionFromPath(path, availableLocales)
50+
: 'docs'
51+
52+
try {
53+
const page = await queryCollection(event, collectionName as keyof Collections)
54+
.path(path)
55+
.select('title', 'path', 'description')
56+
.first() as { title: string, path: string, description: string } | null
57+
58+
if (!page)
59+
throw createError({ statusCode: 404, message: 'Page not found' })
60+
61+
// Use the canonical `page.path` from the database (not the raw user
62+
// input `path`) so non-canonical casing or trailing slashes don't
63+
// produce a 404 when fetching the raw markdown. Normalize so we always
64+
// produce `/raw<path>.md` with a single leading slash.
65+
const rawPath = `/raw${withLeadingSlash(page.path)}.md`
66+
const content = await event.$fetch<string>(rawPath)
67+
68+
return {
69+
title: page.title,
70+
path: page.path,
71+
description: page.description,
72+
content,
73+
url: siteUrl ? joinURL(siteUrl, baseURL, page.path) : joinURL(baseURL, page.path),
74+
}
75+
}
76+
catch (error) {
77+
if (
78+
error
79+
&& typeof error === 'object'
80+
&& 'statusCode' in error
81+
&& (error as { statusCode?: number }).statusCode === 404
82+
) {
83+
throw error
84+
}
85+
throw createError({ statusCode: 500, message: 'Failed to get page' })
86+
}
87+
},
88+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Collections } from '@nuxt/content'
2+
import { queryCollection } from '@nuxt/content/server'
3+
import { joinURL } from 'ufo'
4+
import { z } from 'zod'
5+
import { inferSiteURL } from '../../../utils/meta'
6+
import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from '../../utils/content'
7+
8+
export default defineMcpTool({
9+
description: `Lists all available documentation pages with their categories and basic information.
10+
11+
WHEN TO USE: Use this tool when you need to EXPLORE or SEARCH for documentation about a topic but don't know the exact page path. Common scenarios:
12+
- "Find documentation about markdown features" - explore available guides
13+
- "Show me all getting started guides" - browse introductory content
14+
- "Search for advanced configuration options" - find specific topics
15+
- User asks general questions without specifying exact pages
16+
- You need to understand the overall documentation structure
17+
18+
WHEN NOT TO USE: If you already know the specific page path (e.g., "/en/getting-started/installation"), use get-page directly instead.
19+
20+
WORKFLOW: This tool returns page titles, descriptions, and paths. After finding relevant pages, use get-page to retrieve the full content of specific pages that match the user's needs.
21+
22+
OUTPUT: Returns a structured list with:
23+
- title: Human-readable page name
24+
- path: Exact path for use with get-page
25+
- description: Brief summary of page content
26+
- locale: Locale code the page belongs to (empty when i18n is not configured)
27+
- url: Full URL for reference`,
28+
annotations: {
29+
readOnlyHint: true,
30+
destructiveHint: false,
31+
idempotentHint: true,
32+
openWorldHint: false,
33+
},
34+
inputSchema: {
35+
locale: z.string().optional().describe('The locale to filter pages by (e.g., "en", "fr")'),
36+
},
37+
inputExamples: [
38+
{ locale: 'en' },
39+
{},
40+
],
41+
cache: '1h',
42+
handler: async ({ locale }) => {
43+
const event = useEvent()
44+
const config = useRuntimeConfig(event)
45+
const publicConfig = config.public as Record<string, unknown>
46+
47+
// Prefer the live request origin so URLs match the host the client
48+
// actually used (works around app.baseURL-only setups in prod).
49+
const siteUrl = getRequestURL(event).origin || inferSiteURL()
50+
const baseURL = config.app?.baseURL || '/'
51+
const availableLocales = getAvailableLocales(publicConfig)
52+
const collections = getCollectionsToQuery(locale, availableLocales)
53+
54+
// Tolerate missing collections (e.g. an i18n locale that isn't registered
55+
// yet) so a single bad collection doesn't break the whole listing.
56+
const allPages = await Promise.all(
57+
collections.map(async (collectionName) => {
58+
try {
59+
const pages = await queryCollection(event, collectionName as keyof Collections)
60+
.select('title', 'path', 'description')
61+
.all() as Array<{ title: string, path: string, description: string }>
62+
63+
return pages
64+
.filter(page => !isNavigationPath(page.path))
65+
.map(page => ({
66+
title: page.title,
67+
path: page.path,
68+
description: page.description,
69+
locale: collectionName.startsWith('docs_') ? collectionName.replace('docs_', '') : '',
70+
url: siteUrl ? joinURL(siteUrl, baseURL, page.path) : joinURL(baseURL, page.path),
71+
}))
72+
}
73+
catch {
74+
return []
75+
}
76+
}),
77+
)
78+
79+
return allPages.flat()
80+
},
81+
})

‎packages/layer/server/routes/sitemap.xml.ts‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,13 @@ import type { Collections } from '@nuxt/content'
22
import { queryCollection } from '@nuxt/content/server'
33
import { joinURL, withoutTrailingSlash } from 'ufo'
44
import { inferSiteURL } from '../../utils/site-url'
5+
import { getAvailableLocales, isNavigationPath } from '../utils/content'
56

67
interface SitemapUrl {
78
loc: string
89
lastmod?: string
910
}
1011

11-
interface RuntimePublicI18n {
12-
locales?: Array<string | { code: string }>
13-
}
14-
15-
function getAvailableLocales(publicConfig: Record<string, unknown>): string[] {
16-
const i18n = publicConfig.i18n as RuntimePublicI18n | undefined
17-
if (!i18n?.locales) return []
18-
return i18n.locales.map(locale => typeof locale === 'string' ? locale : locale.code)
19-
}
20-
21-
function isNavigationPath(path: string): boolean {
22-
return path.endsWith('.navigation') || path.includes('/.navigation/')
23-
}
24-
2512
function escapeXml(value: string): string {
2613
return value.replace(/[<>&'"]/g, (char) => {
2714
switch (char) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
interface LocaleEntry {
2+
code: string
3+
}
4+
5+
interface RuntimePublicI18n {
6+
locales?: Array<string | LocaleEntry>
7+
}
8+
9+
/**
10+
* Resolve the available i18n locale codes from a runtime public config object.
11+
* Returns an empty array when i18n is not configured.
12+
*/
13+
export function getAvailableLocales(publicConfig: Record<string, unknown>): string[] {
14+
const i18n = publicConfig.i18n as RuntimePublicI18n | undefined
15+
if (!i18n?.locales)
16+
return []
17+
return i18n.locales.map(locale => typeof locale === 'string' ? locale : locale.code)
18+
}
19+
20+
/**
21+
* Build the list of content collection names to query based on the requested
22+
* locale and the available i18n locales.
23+
*
24+
* - When a specific locale is given on an i18n site:
25+
* - if recognized → `['docs_<locale>']`
26+
* - if not recognized → `[]` (so the caller returns no pages instead of
27+
* silently falling back to every other language)
28+
* - When a specific locale is given on a non-i18n site → `['docs']`
29+
* (single-locale sites have no concept of "wrong locale")
30+
* - When no locale is given and i18n is configured → one collection per
31+
* locale (`docs_<lc>`)
32+
* - Otherwise → `['docs']`
33+
*/
34+
export function getCollectionsToQuery(locale: string | undefined, availableLocales: string[]): string[] {
35+
if (locale) {
36+
if (availableLocales.length === 0)
37+
return ['docs']
38+
return availableLocales.includes(locale) ? [`docs_${locale}`] : []
39+
}
40+
41+
return availableLocales.length > 0
42+
? availableLocales.map(l => `docs_${l}`)
43+
: ['docs']
44+
}
45+
46+
/**
47+
* Whether the given content path points at a `.navigation` metadata file
48+
* (those are internal and should be excluded from public listings).
49+
*/
50+
export function isNavigationPath(path: string): boolean {
51+
return path.endsWith('.navigation') || path.includes('/.navigation/')
52+
}
53+
54+
/**
55+
* Derive the content collection name from a content path. If the path's first
56+
* segment is a known locale, return the locale-suffixed `docs_<lc>` collection,
57+
* otherwise return the default `docs` collection.
58+
*/
59+
export function getCollectionFromPath(path: string, availableLocales: string[]): string {
60+
const pathSegments = path.split('/').filter(Boolean)
61+
const firstSegment = pathSegments[0]
62+
63+
if (firstSegment && availableLocales.includes(firstSegment))
64+
return `docs_${firstSegment}`
65+
66+
return 'docs'
67+
}

0 commit comments

Comments
 (0)