Skip to content

Commit 5f0b0b4

Browse files
Fix docs MCP content fetch on Vercel (#711)
* Fix docs MCP content fetch on Vercel * Use static docs index for MCP * Use native SQLite for Nuxt Content * Remove better-sqlite3 build approval * Fix MCP static fetch origin * Handle MCP doc fetch failures
1 parent 2875618 commit 5f0b0b4

8 files changed

Lines changed: 200 additions & 26 deletions

File tree

modules/content-markdown.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
33
import { dirname, join, relative, sep } from 'node:path';
44
import { defineNuxtModule, useLogger } from '@nuxt/kit';
55
import { contentToMarkdown } from '../server/utils/contentToMarkdown';
6+
import { parseMcpMarkdown } from '../server/utils/mcpMarkdown';
7+
8+
interface McpDocIndexEntry {
9+
title: string;
10+
path: string;
11+
description: string;
12+
}
613

714
export default defineNuxtModule({
815
meta: { name: 'content-markdown' },
@@ -27,6 +34,7 @@ export default defineNuxtModule({
2734
}
2835

2936
let written = 0;
37+
const index: McpDocIndexEntry[] = [];
3038
for (const file of markdownFiles) {
3139
const rel = relative(contentDir, file);
3240
const segments = rel.split(sep);
@@ -35,14 +43,24 @@ export default defineNuxtModule({
3543
const route = toRoute(segments);
3644
const raw = await readFile(file, 'utf8');
3745
const md = contentToMarkdown(raw, partials);
46+
const path = route === '/index' ? '/' : route;
47+
const page = parseMcpMarkdown(md, path);
3848

3949
const outPath = join(outDir, route + '.md');
4050
await mkdir(dirname(outPath), { recursive: true });
4151
await writeFile(outPath, md, 'utf8');
52+
index.push({
53+
title: page.title,
54+
path,
55+
description: page.description,
56+
});
4257
written++;
4358
}
4459

45-
logger.info(`Wrote ${written} content markdown files`);
60+
index.sort((a, b) => a.path.localeCompare(b.path));
61+
await writeFile(join(outDir, 'mcp-docs-index.json'), JSON.stringify(index, null, 2), 'utf8');
62+
63+
logger.info(`Wrote ${written} content markdown files and ${index.length} MCP index entries`);
4664
});
4765
},
4866
});

nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export default defineNuxtConfig({
8686
},
8787

8888
content: {
89+
experimental: {
90+
sqliteConnector: 'native',
91+
},
8992
build: {
9093
markdown: {
9194
toc: {

package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,7 @@
7171
"overrides": {
7272
"vite": "npm:rolldown-vite@latest",
7373
"h3": "1.15.11"
74-
},
75-
"onlyBuiltDependencies": [
76-
"better-sqlite3"
77-
]
74+
}
7875
},
7976
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8",
8077
"engines": {

server/mcp/tools/get-doc.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server';
2-
import { queryCollection } from '@nuxt/content/server';
2+
import { ofetch } from 'ofetch';
33
import { useEvent } from 'nitropack/runtime';
44
import { z } from 'zod';
5+
import { normalizeDocPath, parseMcpMarkdown } from '../../utils/mcpMarkdown';
6+
import { getMcpMarkdownPath, getMcpStaticBaseUrl } from '../../utils/mcpStatic';
57

68
const BASE_PATH = '/docs';
79

@@ -20,22 +22,51 @@ export default defineMcpTool({
2022
const event = useEvent();
2123
const config = useRuntimeConfig();
2224
const siteOrigin = config.public.siteUrl.replace(/\/$/, '');
23-
const normalized = path.startsWith('/') ? path : `/${path}`;
25+
const normalized = normalizeDocPath(path);
2426

25-
const page = await queryCollection(event, 'content')
26-
.where('path', '=', normalized)
27-
.first();
27+
let markdown: string;
28+
try {
29+
markdown = await ofetch(`${getMcpStaticBaseUrl()}${getMcpMarkdownPath(normalized)}.md`, { responseType: 'text' });
30+
}
31+
catch (error) {
32+
if (!import.meta.dev) {
33+
if (fetchStatusCode(error) !== 404) {
34+
throw createError({ statusCode: 503, message: `Doc markdown unavailable for ${normalized}` });
35+
}
36+
throw createError({ statusCode: 404, message: `No doc found at ${normalized}` });
37+
}
38+
39+
const { queryCollection } = await import('@nuxt/content/server');
40+
const page = await queryCollection(event, 'content')
41+
.where('path', '=', normalized)
42+
.first();
43+
44+
if (!page) {
45+
throw createError({ statusCode: 404, message: `No doc found at ${normalized}` });
46+
}
2847

29-
if (!page) {
30-
throw createError({ statusCode: 404, message: `No doc found at ${normalized}` });
48+
return {
49+
title: page.title,
50+
path: page.path,
51+
description: page.description ?? '',
52+
content: page.rawbody ?? '',
53+
url: `${siteOrigin}${BASE_PATH}${page.path}`,
54+
};
3155
}
3256

57+
const page = parseMcpMarkdown(markdown, normalized);
58+
3359
return {
3460
title: page.title,
35-
path: page.path,
61+
path: normalized,
3662
description: page.description ?? '',
37-
content: page.rawbody ?? '',
38-
url: `${siteOrigin}${BASE_PATH}${page.path}`,
63+
content: page.content,
64+
url: `${siteOrigin}${BASE_PATH}${normalized}`,
3965
};
4066
},
4167
});
68+
69+
function fetchStatusCode(error: unknown): number | undefined {
70+
if (!error || typeof error !== 'object' || !('response' in error)) return;
71+
return (error as { response?: { status?: number } }).response?.status;
72+
}

server/mcp/tools/list-docs.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import { defineMcpTool } from '@nuxtjs/mcp-toolkit/server';
2-
import { queryCollection } from '@nuxt/content/server';
2+
import { ofetch } from 'ofetch';
33
import { useEvent } from 'nitropack/runtime';
44
import { z } from 'zod';
55
import { docsSections } from '#shared/utils/docsSections';
6+
import { getMcpStaticBaseUrl } from '../../utils/mcpStatic';
67

78
const BASE_PATH = '/docs';
89

910
const allPrefixes = Array.from(new Set(docsSections.flatMap(s => s.prefixes))).sort();
1011
const prefixDescription = `Path prefix to filter by. Must start with "/". Valid prefixes: ${allPrefixes.join(', ')}.`;
1112

13+
interface McpDocIndexEntry {
14+
title: string;
15+
path: string;
16+
description: string;
17+
}
18+
1219
export default defineMcpTool({
1320
name: 'list-docs',
1421
title: 'List Directus docs',
@@ -27,22 +34,40 @@ export default defineMcpTool({
2734
const event = useEvent();
2835
const config = useRuntimeConfig();
2936
const siteOrigin = config.public.siteUrl.replace(/\/$/, '');
30-
let query = queryCollection(event, 'content')
31-
.where('path', 'NOT LIKE', '%/.%')
32-
.where('path', 'NOT LIKE', '%/_partials/%')
33-
.order('path', 'ASC');
37+
const rows = await loadDocIndex(event);
38+
const filtered = pathPrefix
39+
? rows.filter(row => row.path.startsWith(pathPrefix))
40+
: rows;
41+
42+
return filtered.slice(0, limit ?? 200).map(row => ({
43+
title: row.title,
44+
path: row.path,
45+
description: row.description ?? '',
46+
url: `${siteOrigin}${BASE_PATH}${row.path}`,
47+
}));
48+
},
49+
});
3450

35-
if (pathPrefix) {
36-
query = query.where('path', 'LIKE', `${pathPrefix}%`);
51+
async function loadDocIndex(event: ReturnType<typeof useEvent>): Promise<McpDocIndexEntry[]> {
52+
try {
53+
return await ofetch<McpDocIndexEntry[]>(`${getMcpStaticBaseUrl()}/mcp-docs-index.json`);
54+
}
55+
catch {
56+
if (!import.meta.dev) {
57+
throw createError({ statusCode: 503, message: 'Docs index unavailable' });
3758
}
3859

39-
const rows = await query.limit(limit ?? 200).all();
60+
const { queryCollection } = await import('@nuxt/content/server');
61+
const rows = await queryCollection(event, 'content')
62+
.where('path', 'NOT LIKE', '%/.%')
63+
.where('path', 'NOT LIKE', '%/_partials/%')
64+
.order('path', 'ASC')
65+
.all();
4066

4167
return rows.map(row => ({
4268
title: row.title,
4369
path: row.path,
4470
description: row.description ?? '',
45-
url: `${siteOrigin}${BASE_PATH}${row.path}`,
4671
}));
47-
},
48-
});
72+
}
73+
}

server/utils/mcpMarkdown.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export interface ParsedMcpMarkdown {
2+
title: string;
3+
description: string;
4+
content: string;
5+
}
6+
7+
export function normalizeDocPath(path: string): string {
8+
return path.startsWith('/') ? path : `/${path}`;
9+
}
10+
11+
export function parseMcpMarkdown(markdown: string, path: string): ParsedMcpMarkdown {
12+
const { frontmatter, body } = splitFrontmatter(markdown);
13+
14+
return {
15+
title: frontmatterValue(frontmatter, 'title') || titleFromMarkdown(body) || path,
16+
description: frontmatterValue(frontmatter, 'description') || '',
17+
content: body,
18+
};
19+
}
20+
21+
function splitFrontmatter(markdown: string): { frontmatter: string; body: string } {
22+
const content = markdown.replace(/^\uFEFF/, '');
23+
if (!content.startsWith('---\n')) return { frontmatter: '', body: content };
24+
25+
const end = content.indexOf('\n---', 4);
26+
if (end === -1) return { frontmatter: '', body: content };
27+
28+
return {
29+
frontmatter: content.slice(4, end).trim(),
30+
body: content.slice(end + 4).replace(/^\n+/, ''),
31+
};
32+
}
33+
34+
function frontmatterValue(frontmatter: string, key: string): string {
35+
const match = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(frontmatter);
36+
if (!match?.[1]) return '';
37+
38+
return match[1]
39+
.trim()
40+
.replace(/^['"]|['"]$/g, '');
41+
}
42+
43+
function titleFromMarkdown(markdown: string): string {
44+
const match = /^#\s+(.+)$/m.exec(markdown);
45+
return match?.[1]?.trim() ?? '';
46+
}

server/utils/mcpStatic.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { normalizeDocPath } from './mcpMarkdown';
2+
3+
export function getMcpStaticBaseUrl(): string {
4+
const config = useRuntimeConfig();
5+
const baseUrl = config.app.baseURL.replace(/\/$/, '');
6+
const siteOrigin = config.public.siteUrl.replace(/\/$/, '');
7+
return `${siteOrigin}${baseUrl}`;
8+
}
9+
10+
export function getMcpMarkdownPath(path: string): string {
11+
const normalized = normalizeDocPath(path);
12+
return normalized === '/' ? '/index' : normalized;
13+
}

tests/server/mcpMarkdown.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normalizeDocPath, parseMcpMarkdown } from '../../server/utils/mcpMarkdown';
3+
import { getMcpMarkdownPath } from '../../server/utils/mcpStatic';
4+
5+
describe('mcpMarkdown', () => {
6+
it('normalizes doc paths', () => {
7+
expect(normalizeDocPath('self-hosting/requirements')).toBe('/self-hosting/requirements');
8+
expect(normalizeDocPath('/self-hosting/requirements')).toBe('/self-hosting/requirements');
9+
});
10+
11+
it('maps root docs path to index markdown', () => {
12+
expect(getMcpMarkdownPath('/')).toBe('/index');
13+
expect(getMcpMarkdownPath('/self-hosting/requirements')).toBe('/self-hosting/requirements');
14+
});
15+
16+
it('parses frontmatter and markdown body', () => {
17+
const page = parseMcpMarkdown(`---
18+
title: Self-Hosting Requirements
19+
description: This page outlines the requirements.
20+
---
21+
22+
# Ignored title
23+
24+
Body
25+
`, '/self-hosting/requirements');
26+
27+
expect(page).toEqual({
28+
title: 'Self-Hosting Requirements',
29+
description: 'This page outlines the requirements.',
30+
content: '# Ignored title\n\nBody\n',
31+
});
32+
});
33+
34+
it('falls back to markdown title', () => {
35+
const page = parseMcpMarkdown('# Page title\n\nBody\n', '/fallback');
36+
37+
expect(page.title).toBe('Page title');
38+
expect(page.description).toBe('');
39+
expect(page.content).toBe('# Page title\n\nBody\n');
40+
});
41+
});

0 commit comments

Comments
 (0)