build: .
This commit is contained in:
commit
3bb8c799ee
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
24
package.json
Normal file
24
package.json
Normal 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
1646
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
24
src/index.ts
Normal file
24
src/index.ts
Normal 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
404
src/util.ts
Normal 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
18
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user