service.ts•9.72 kB
import {
McpServer,
ResourceTemplate,
} from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { join } from 'path'
import {
getSiteMap,
url2LocalPath,
getMDXFileContent,
DOC_ROUTE_BASE_PATH_MAPPING,
} from './utils/mdx.js'
import { postMultiSearch } from './utils/search.js'
import { z } from 'zod'
const DOCS_MD_DIR = join(process.cwd(), '.docs')
const DOCS_DIST_DIR = join(process.cwd(), '.docs')
interface SearchResult {
path: string
title?: string
score?: number
content?: string
}
interface DocInfo {
path: string
content: string
}
interface CodeSuggestion {
type: string
description: string
code: string
}
export const createMcpServer = () => {
const server = new McpServer({
name: 'MDX Documentation Server',
version: '1.0.0',
})
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
// server.resource(
// 'docs',
// new ResourceTemplate('docs://{path}', {
// list: async () => {
// const siteMapFile = join(DOCS_DIST_DIR, 'sitemap.xml')
// const siteMapContent = await getSiteMap(siteMapFile)
// return {
// resources: siteMapContent.map((file: { loc: string }) => {
// const encodedPath = encodeURIComponent(file.loc)
// return {
// name: file.loc,
// uri: `docs://${encodedPath}`,
// description: file.loc,
// }
// }),
// }
// },
// }),
// async (uri, { path }) => {
// const decodedPath = decodeURIComponent(`${path}`)
// const localPath = url2LocalPath(decodedPath)
// if (!localPath) {
// throw new Error(`Invalid URL: ${uri.href}`)
// }
// const mdxFile = await getMDXFileContent(join(DOCS_MD_DIR, localPath))
// return {
// contents: [
// {
// uri: uri.href,
// text: mdxFile,
// },
// ],
// }
// }
// )
server.tool(
'search-docs',
'search for documentation by query',
{
query: z.string().describe('search query'),
},
async ({ query }) => {
try {
const res = await postMultiSearch(query)
return {
content: [{ type: 'text', text: JSON.stringify(res) }],
}
} catch (err: unknown) {
const error = err as Error
return {
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
isError: true,
}
}
}
)
// server.tool(
// 'get-doc-structure',
// {
// category: z.string().optional().default(''),
// },
// async ({ category }) => {
// try {
// const siteMapFile = join(DOCS_DIST_DIR, 'sitemap.xml')
// const siteMapContent = await getSiteMap(siteMapFile)
// let filteredContent = siteMapContent
// if (category) {
// filteredContent = siteMapContent.filter((item: { loc: string }) =>
// item.loc.toLowerCase().includes(category.toLowerCase())
// )
// }
// const structure = filteredContent.map((item: { loc: string }) => {
// const parts = item.loc.split('/')
// return {
// path: item.loc,
// name: parts[parts.length - 1] || parts[parts.length - 2],
// level: parts.length - 1,
// category: parts.length > 1 ? parts[0] : '根目录',
// uri: `docs://${encodeURIComponent(item.loc)}`,
// text: item,
// }
// })
// return {
// content: [
// {
// type: 'text',
// text: JSON.stringify(
// {
// total: structure.length,
// categories: [
// ...new Set(
// structure.map(
// (item: { category: string }) => item.category
// )
// ),
// ],
// structure,
// },
// null,
// 2
// ),
// },
// ],
// }
// } catch (err: unknown) {
// const error = err as Error
// return {
// content: [
// {
// type: 'text',
// text: `获取文档结构时出错: ${error.message}`,
// },
// ],
// isError: true,
// }
// }
// }
// )
server.tool(
'list-docs',
'list documents by category',
{
category: z
.string()
.nullable()
.optional()
.default(null)
.describe('document category'),
limit: z
.number()
.nullable()
.optional()
.default(0)
.describe('number of documents to return'),
sortBy: z
.enum(['path', 'name'])
.nullable()
.optional()
.default('name')
.describe('sort order(path or name)'),
},
async ({ category, limit, sortBy }) => {
try {
const actualLimit = limit ?? 0
console.log('actualLimit 1', actualLimit, 'limit', limit)
const actualSortBy = sortBy ?? 'name'
const siteMapFile = join(DOCS_DIST_DIR, 'sitemap.xml')
const siteMapContent = await getSiteMap(siteMapFile)
// filter categories
const filteredDocs = []
if (category) {
const filteredByCategory = siteMapContent.filter(
(item: { loc: string }) =>
item.loc.toLowerCase().includes(category.toLowerCase())
)
filteredDocs.push(...filteredByCategory)
} else {
filteredDocs.push(...siteMapContent)
}
// 格式化文档列表
let docsList = filteredDocs.map(
(item: { loc: string; category?: string }) => {
const parts = item.loc.split('/')
const lastPartIndex = parts.length - 1
const lastPart = lastPartIndex >= 0 ? parts[lastPartIndex] : ''
const decodedPath = decodeURIComponent(`${item.loc}`)
const localPath = url2LocalPath(decodedPath)
return {
path: item.loc,
name: lastPart,
category: item.category,
depth: parts.length,
uri: `docs://${localPath}`,
localPath: localPath,
}
}
)
// 排序
if (actualSortBy === 'name') {
docsList.sort((a: { name: string }, b: { name: any }) =>
a.name.localeCompare(b.name)
)
} else {
docsList.sort((a: { path: string }, b: { path: any }) =>
a.path.localeCompare(b.path)
)
}
// 限制结果数量
if (actualLimit > 0) {
docsList = docsList.slice(0, actualLimit)
}
console.log('docsList', docsList.length, 'actualLimit', actualLimit)
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
categories: Object.keys(DOC_ROUTE_BASE_PATH_MAPPING),
total: docsList.length,
docs: docsList,
},
null,
2
),
},
],
}
} catch (err: unknown) {
const error = err as Error
return {
content: [
{
type: 'text',
text: `获取文档列表时出错: ${error.message}`,
},
],
isError: true,
}
}
}
)
server.tool(
'get-doc-content',
'get document markdown content by URI',
{
uri: z.string().describe('document URI'),
},
async ({ uri }) => {
try {
// 解析URI
if (!uri.startsWith('docs://')) {
throw new Error('Invalid URI format, must start with docs://')
}
const encodedPath = uri.replace('docs://', '')
// const decodedPath = decodeURIComponent(encodedPath)
// const localPath = url2LocalPath(decodedPath)
const localPath = encodedPath
if (!localPath) {
throw new Error(`Invalid local path: ${uri}`)
}
// 获取文档内容
const mdxContent = await getMDXFileContent(join(DOCS_MD_DIR, localPath))
return {
content: [
{
type: 'text',
text: mdxContent,
},
],
}
} catch (err: unknown) {
const error = err as Error
return {
content: [
{
type: 'text',
text: `Error when retrieving document content: ${error.message}`,
},
],
isError: true,
}
}
}
)
server.prompt('doc', { question: z.string() }, ({ question }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Please read this question: ${question}.\n\nThen extract the key words and query by #search-docs tool. You can get document URI from response. \n\nThen get document content by #get-doc-content tool. You can get document content from response.\n\nFinally, please give me a summary of the document content and the code suggestion if any.`,
},
},
],
}))
return {
server,
transport,
setupServer: async () => {
await server.connect(transport)
},
}
}