This commit is contained in:
陶林 2025-05-30 10:26:00 +08:00
commit 3bb8c799ee
6 changed files with 2118 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "@taoya7/swagger-mcp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"files": [
"dist"
],
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\""
},
"packageManager": "pnpm@10.11.0",
"devDependencies": {
"@types/node": "^22.15.26",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"dependencies": {
"@scalar/openapi-parser": "^0.13.0",
"axios": "^1.9.0",
"fastmcp": "^2.1.4",
"zod": "^3.25.40"
}
}

1646
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

24
src/index.ts Normal file
View File

@ -0,0 +1,24 @@
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { filterOpenAPIByPathPrefix } from './util'
const server = new FastMCP({
name: "前端联调后端MCP",
version: "1.0.0",
});
server.addTool({
name: "开发联调获取接口文档信息",
description: "fetch swagger or backend api infomation",
parameters: z.object({
jsonurl: z.string(),
pathPrefix: z.string(),
}),
execute: async (args) => {
return String(filterOpenAPIByPathPrefix(args.jsonurl, args.pathPrefix));
},
});
server.start({
transportType: "stdio",
});

404
src/util.ts Normal file
View File

@ -0,0 +1,404 @@
import { upgrade, sanitize } from '@scalar/openapi-parser'
import fs from 'fs'
import axios from 'axios'
interface FilterOptions {
pathPrefix?: string
}
interface FilterResult {
json: any
markdown: string
}
function generateMarkdownDoc(filteredSpecification: any, pathPrefix: string): string {
let markdown = `# API 文档 - ${pathPrefix} 接口\n\n`
if (filteredSpecification.info) {
markdown += `**版本**: ${filteredSpecification.info.version || 'N/A'}\n`
markdown += `**描述**: ${filteredSpecification.info.description || '无描述'}\n\n`
}
const paths = filteredSpecification.paths || {}
Object.entries(paths).forEach(([path, pathItem]: [string, any]) => {
Object.entries(pathItem).forEach(([method, operation]: [string, any]) => {
if (typeof operation === 'object' && operation !== null) {
markdown += `## ${operation.summary || path}\n\n`
markdown += `### 接口描述\n`
markdown += `${operation.description || operation.summary || '无描述'}\n\n`
markdown += `**请求方法**: ${method.toUpperCase()}\n\n`
markdown += `**请求地址**: ${path}\n\n`
// 请求参数
if (operation.parameters && operation.parameters.length > 0) {
markdown += `### 请求参数\n\n`
markdown += `| 参数名 | 类型 | 位置 | 必填 | 描述 |\n`
markdown += `|--------|------|------|------|------|\n`
operation.parameters.forEach((param: any) => {
const required = param.required ? '是' : '否'
const type = param.schema?.type || param.type || 'string'
const location = param.in || 'query'
const description = param.description || '无描述'
markdown += `| ${param.name} | ${type} | ${location} | ${required} | ${description} |\n`
})
markdown += '\n'
}
// 请求体
if (operation.requestBody) {
markdown += `### 请求体\n\n`
const content = operation.requestBody.content
if (content) {
Object.entries(content).forEach(([mediaType, mediaTypeObj]: [string, any]) => {
markdown += `**Content-Type**: ${mediaType}\n\n`
if (mediaTypeObj.schema) {
markdown += generateSchemaTable(mediaTypeObj.schema, filteredSpecification.components?.schemas)
}
})
}
markdown += '\n'
}
// 响应参数
if (operation.responses) {
markdown += `### 响应参数\n\n`
Object.entries(operation.responses).forEach(([statusCode, response]: [string, any]) => {
markdown += `#### ${statusCode} 响应\n\n`
markdown += `**描述**: ${response.description || '无描述'}\n\n`
if (response.content) {
Object.entries(response.content).forEach(([mediaType, mediaTypeObj]: [string, any]) => {
markdown += `**Content-Type**: ${mediaType}\n\n`
if (mediaTypeObj.schema) {
markdown += generateSchemaTable(mediaTypeObj.schema, filteredSpecification.components?.schemas)
}
})
}
markdown += '\n'
})
}
markdown += '---\n\n'
}
})
})
return markdown
}
function generateSchemaTable(schema: any, components?: any): string {
let table = `| 字段名 | 类型 | 必填 | 描述 |\n`
table += `|--------|------|------|------|\n`
function processSchema(currentSchema: any, prefix = ''): void {
if (currentSchema.$ref) {
const refName = currentSchema.$ref.replace('#/components/schemas/', '')
if (components && components[refName]) {
processSchema(components[refName], prefix)
}
return
}
if (currentSchema.type === 'object' && currentSchema.properties) {
Object.entries(currentSchema.properties).forEach(([propName, propSchema]: [string, any]) => {
const fullName = prefix ? `${prefix}.${propName}` : propName
const required = currentSchema.required?.includes(propName) ? '是' : '否'
const type = getSchemaType(propSchema)
const description = propSchema.description || '无描述'
table += `| ${fullName} | ${type} | ${required} | ${description} |\n`
// 递归处理嵌套对象
if (propSchema.type === 'object' && propSchema.properties) {
processSchema(propSchema, fullName)
} else if (propSchema.$ref && components) {
const refName = propSchema.$ref.replace('#/components/schemas/', '')
if (components[refName]) {
processSchema(components[refName], fullName)
}
}
})
} else if (currentSchema.type === 'array' && currentSchema.items) {
const type = `array<${getSchemaType(currentSchema.items)}>`
table += `| ${prefix || 'items'} | ${type} | - | 数组项 |\n`
if (currentSchema.items.$ref && components) {
const refName = currentSchema.items.$ref.replace('#/components/schemas/', '')
if (components[refName]) {
processSchema(components[refName], prefix || 'items')
}
}
}
}
processSchema(schema)
return table + '\n'
}
function getSchemaType(schema: any): string {
if (schema.$ref) {
return schema.$ref.replace('#/components/schemas/', '')
}
if (schema.type === 'array') {
return `array<${getSchemaType(schema.items)}>`
}
return schema.type || 'unknown'
}
async function loadOpenAPIDocument(input: string): Promise<any> {
console.log(`正在读取 OpenAPI 文档: ${input}`)
// 判断是否为 URL
if (input.startsWith('http://') || input.startsWith('https://')) {
console.log('检测到在线地址,正在下载...')
try {
const response = await axios.get(input, {
timeout: 30000, // 30秒超时
headers: {
'Accept': 'application/json, text/plain, */*',
'User-Agent': 'OpenAPI-Parser/1.0'
}
})
if (typeof response.data === 'string') {
return JSON.parse(response.data)
}
return response.data
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`下载文档失败: ${error.message}`)
}
throw error
}
} else {
// 本地文件
console.log('检测到本地文件,正在读取...')
try {
const file = fs.readFileSync(input, 'utf-8')
return JSON.parse(file)
} catch (error) {
throw new Error(`读取本地文件失败: ${error}`)
}
}
}
async function filterOpenAPIByPathPrefix(
input: string,
pathPrefix: string,
options: FilterOptions = {}
): Promise<FilterResult> {
try {
// 读取并解析原始文档
const json = await loadOpenAPIDocument(input)
const { specification } = upgrade(json)
const clearSpecification = sanitize(specification)
console.log(`正在筛选路径前缀为 "${pathPrefix}" 的接口...`)
const filteredPaths = Object.entries(clearSpecification.paths || {})
.filter(([path]) => path.startsWith(pathPrefix))
.reduce((acc, [path, pathItem]) => {
acc[path] = pathItem
return acc
}, {} as any)
const pathCount = Object.keys(filteredPaths).length
console.log(`找到 ${pathCount} 个匹配的路径`)
if (pathCount === 0) {
console.warn(`警告: 没有找到以 "${pathPrefix}" 开头的路径`)
}
// 收集筛选路径中引用的所有 schema 和 tags
const usedSchemas = new Set<string>()
const usedTags = new Set<string>()
function collectSchemaRefs(obj: any) {
if (typeof obj === 'object' && obj !== null) {
if (obj.$ref && typeof obj.$ref === 'string') {
const match = obj.$ref.match(/#\/components\/schemas\/(.+)/)
if (match) {
usedSchemas.add(match[1])
}
}
if (Array.isArray(obj)) {
obj.forEach(item => collectSchemaRefs(item))
} else {
Object.values(obj).forEach(value => collectSchemaRefs(value))
}
}
}
function collectTags(obj: any) {
if (typeof obj === 'object' && obj !== null) {
if (obj.tags && Array.isArray(obj.tags)) {
obj.tags.forEach((tag: string) => usedTags.add(tag))
}
if (Array.isArray(obj)) {
obj.forEach(item => collectTags(item))
} else if (typeof obj === 'object') {
Object.values(obj).forEach(value => collectTags(value))
}
}
}
// 遍历筛选出的路径,收集所有引用的 schema 和 tags
collectSchemaRefs(filteredPaths)
collectTags(filteredPaths)
// 递归收集依赖的 schema
function collectDependentSchemas(schemaName: string, schemas: any) {
if (schemas[schemaName]) {
collectSchemaRefs(schemas[schemaName])
}
}
// 继续收集依赖的 schema直到没有新的依赖
let previousSize = 0
while (usedSchemas.size !== previousSize) {
previousSize = usedSchemas.size
const currentSchemas = Array.from(usedSchemas)
currentSchemas.forEach(schemaName => {
if (clearSpecification.components?.schemas) {
collectDependentSchemas(schemaName, clearSpecification.components.schemas)
}
})
}
// 创建筛选后的文档
const filteredSpecification = {
...clearSpecification,
paths: filteredPaths,
info: {
...clearSpecification.info,
title: `${clearSpecification.info?.title || 'API'} - ${pathPrefix} 接口`,
description: `筛选出路径前缀为 "${pathPrefix}" 的接口文档`
}
}
// 只保留使用到的 schemas
if (filteredSpecification.components?.schemas && usedSchemas.size > 0) {
const filteredSchemas: any = {}
usedSchemas.forEach(schemaName => {
if (filteredSpecification.components.schemas[schemaName]) {
filteredSchemas[schemaName] = filteredSpecification.components.schemas[schemaName]
}
})
filteredSpecification.components.schemas = filteredSchemas
console.log(`保留了 ${usedSchemas.size} 个相关的 schema models`)
} else if (filteredSpecification.components?.schemas) {
// 如果没有使用到任何 schema则删除 schemas
delete filteredSpecification.components.schemas
console.log('没有找到相关的 schema models已移除 schemas 部分')
}
// 只保留使用到的 tags
if (filteredSpecification.tags && usedTags.size > 0) {
const filteredTags = filteredSpecification.tags.filter((tag: any) =>
usedTags.has(tag.name)
)
filteredSpecification.tags = filteredTags
console.log(`保留了 ${filteredTags.length} 个相关的 tags`)
} else if (filteredSpecification.tags) {
// 如果没有使用到任何 tag则删除 tags
delete filteredSpecification.tags
console.log('没有找到相关的 tags已移除 tags 部分')
}
// 如果 components 为空,则完全移除
if (filteredSpecification.components && Object.keys(filteredSpecification.components).length === 0) {
delete filteredSpecification.components
}
// 生成 Markdown 文档
const markdown = generateMarkdownDoc(filteredSpecification, pathPrefix)
// 显示筛选结果摘要
console.log('\n=== 筛选结果摘要 ===')
console.log(`原始路径数量: ${Object.keys(clearSpecification.paths || {}).length}`)
console.log(`筛选后路径数量: ${pathCount}`)
console.log(`筛选的路径前缀: ${pathPrefix}`)
console.log(`相关 schemas 数量: ${usedSchemas.size}`)
console.log(`相关 tags 数量: ${usedTags.size}`)
if (pathCount > 0) {
console.log('\n筛选出的路径:')
Object.keys(filteredPaths).forEach(path => {
console.log(` - ${path}`)
})
}
if (usedTags.size > 0) {
console.log('\n相关的 tags:')
Array.from(usedTags).forEach(tag => {
console.log(` - ${tag}`)
})
}
return {
json: filteredSpecification,
markdown: markdown
}
} catch (error) {
console.error('处理过程中发生错误:', error)
throw error
}
}
// 主函数
async function main() {
const input = './api-docs.json'
const pathPrefix = '/teamCheckNameDetail'
try {
const result = await filterOpenAPIByPathPrefix(input, pathPrefix)
console.log('\n✅ 文档筛选完成!')
console.log(`JSON 文档大小: ${JSON.stringify(result.json).length} 字符`)
console.log(`Markdown 文档大小: ${result.markdown.length} 字符`)
} catch (error) {
console.error('❌ 程序执行失败:', error)
process.exit(1)
}
}
// 支持命令行参数
if (require.main === module) {
const args = process.argv.slice(2)
if (args.length >= 2) {
const input = args[0] // 可以是本地文件路径或在线URL
const pathPrefix = args[1]
filterOpenAPIByPathPrefix(input, pathPrefix).then(result => {
console.log('\n✅ 文档筛选完成!')
console.log(`JSON 文档大小: ${JSON.stringify(result.json).length} 字符`)
console.log(`Markdown 文档大小: ${result.markdown.length} 字符`)
}).catch(error => {
console.error('❌ 程序执行失败:', error)
process.exit(1)
})
} else if (args.length === 1) {
// 只提供了一个参数,当作路径前缀,使用默认的本地文件
const pathPrefix = args[0]
filterOpenAPIByPathPrefix('./api-docs.json', pathPrefix).then(result => {
console.log('\n✅ 文档筛选完成!')
console.log(`JSON 文档大小: ${JSON.stringify(result.json).length} 字符`)
console.log(`Markdown 文档大小: ${result.markdown.length} 字符`)
}).catch(error => {
console.error('❌ 程序执行失败:', error)
process.exit(1)
})
} else {
main()
}
}
export { filterOpenAPIByPathPrefix }

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}