mock数据的mcp

0 阅读4分钟

摘要

当前项目组在使用 YAPI 生成 Mock 数据的过程中,发现其生成的 Mock 数据粒度较粗,无法满足精细化的开发测试需求。为解决这一问题,计划开发 MCP(Mock Control Platform)平台来替代现有 YAPI 的 Mock 数据功能,旨在提供更贴合业务场景、更精准且可灵活配置的 Mock 数据服务,提升前端(涵盖 Vue、React、React Native、Uniapp 多技术栈)开发过程中接口联调、功能测试的效率和准确性。

总结

  1. 核心问题:YAPI 生成的 Mock 数据粗糙,无法满足精细化开发测试需求;
  2. 解决方案:开发 MCP 平台替代 YAPI 的 Mock 数据功能;
  3. 核心目标:提供精准、灵活、贴合业务的 Mock 数据,适配多技术栈开发场景,提升开发测试效率。

核心代码 index.cjs

/* eslint-env node */
/* global process, console, require, __dirname */
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs');
const path = require('node:path');

// 确保从项目根目录解析模块
const projectRoot = path.resolve(__dirname, '../..');
const nodeModulesPath = path.join(projectRoot, 'node_modules');

// 设置 NODE_PATH 确保能找到模块
if (!process.env.NODE_PATH) {
    process.env.NODE_PATH = nodeModulesPath;
} else {
    process.env.NODE_PATH = nodeModulesPath + path.delimiter + process.env.NODE_PATH;
}
const Module = require('module');
Module._initPaths();

// 使用绝对路径 require 模块
const ts = require(path.join(nodeModulesPath, 'typescript'));
const { Server } = require(path.join(nodeModulesPath, '@modelcontextprotocol/sdk/dist/cjs/server/index.js'));
const { StdioServerTransport } = require(path.join(nodeModulesPath, '@modelcontextprotocol/sdk/dist/cjs/server/stdio.js'));
const { ListToolsRequestSchema, CallToolRequestSchema } = require(path.join(
    nodeModulesPath,
    '@modelcontextprotocol/sdk/dist/cjs/types.js'
));

const YAPI_DIR = process.env.YAPI_DIR ? path.resolve(process.env.YAPI_DIR) : path.join(projectRoot, 'apps/capacitor/src/yapi');

const state = {
    program: null,
    checker: null,
    functionToType: new Map(),
    typeNodes: new Map()
};

// For any image-like field, always return a real reachable resource.
const DEFAULT_MOCK_IMAGE_URL =
    'https://xxxxxx.com/digital-food-test/product/897185091627843616/1745458813605.jpg';

async function startServer() {
    const server = new Server({ name: 'yapi-mock-generator', version: '0.1.0' }, { capabilities: { tools: {} } });

    server.setRequestHandler(ListToolsRequestSchema, () => ({
        tools: [
            {
                name: 'generateMock',
                description: '根据接口函数名生成 mock 数据并保存为 JSON 文件。文件命名格式:mock-接口名.json。如果提供了 contextPath(代码文件路径),则在与代码上下文同级的目录生成;否则保存在项目根目录的 mocks/ 文件夹中。例如:postMarketCouponMyCoupons',
                inputSchema: {
                    type: 'object',
                    properties: {
                        functionName: {
                            type: 'string',
                            description: '接口函数名,例如:postMarketCouponMyCoupons。生成的文件名为:mock-postMarketCouponMyCoupons.json'
                        },
                        pageSize: {
                            type: 'number',
                            description: '列表大小(可选)'
                        },
                        total: {
                            type: 'number',
                            description: '总数(可选)'
                        },
                        contextPath: {
                            type: 'string',
                            description: '代码文件的路径(可选)。如果提供,mock 文件将生成在该文件同级的目录中。例如:apps/capacitor/src/pages/red-packet-merchant-activity/index.tsx'
                        }
                    },
                    required: ['functionName']
                }
            }
        ]
    }));

    server.setRequestHandler(CallToolRequestSchema, async (request) => {
        const { name, arguments: args } = request.params;
        if (name === 'generateMock') {
            const functionName = String(args?.functionName || '');
            const mockConfig = {
                pageSize: args?.pageSize,
                total: args?.total,
                listSize: args?.pageSize
            };
            const mock = generateMockFromFunction(functionName, mockConfig);

            // 生成文件路径:mock-接口名.json
            // 规则:如果提供了 contextPath,则在与代码上下文同级的目录生成;否则保存在项目根目录的 mocks/ 目录
            const fileName = `mock-${functionName}.json`;
            let targetDir;
            let relativePath;

            if (args?.contextPath) {
                // 如果提供了 contextPath,在与代码文件同级的目录生成
                const contextPath = String(args.contextPath);
                const resolvedContextPath = path.isAbsolute(contextPath)
                    ? contextPath
                    : path.join(projectRoot, contextPath);
                const contextDir = path.dirname(resolvedContextPath);
                targetDir = contextDir;
                const filePath = path.join(contextDir, fileName);
                relativePath = path.relative(projectRoot, filePath).replace(/\\/g, '/');
            } else {
                // 默认:在项目根目录的 mocks/ 目录生成
                targetDir = path.join(projectRoot, 'mocks');
                relativePath = `mocks/${fileName}`;
            }

            // 确保目标目录存在
            if (!fs.existsSync(targetDir)) {
                fs.mkdirSync(targetDir, { recursive: true });
            }

            const filePath = path.join(targetDir, fileName);
            const jsonText = JSON.stringify(mock, null, 2);

            // 写入文件
            fs.writeFileSync(filePath, jsonText, 'utf8');

            // 返回文件路径和内容
            return {
                content: [
                    {
                        type: 'text',
                        text: `✅ Mock 数据已生成!\n\n文件路径: ${relativePath}\n文件大小: ${jsonText.length} 字节\n\n文件内容预览:\n\`\`\`json\n${jsonText.substring(0, 500)}${jsonText.length > 500 ? '...' : ''}\n\`\`\``
                    }
                ]
            };
        }
        throw new Error(`Unknown tool: ${name}`);
    });

    await server.connect(new StdioServerTransport());
}

function ensureProgram() {
    if (state.program && state.checker) {
        return;
    }
    const files = getYapiFiles();
    const normalizePathKey = (value) => path.resolve(value).replace(/\\/g, '/').toLowerCase();
    const fileSet = new Set(files.map(normalizePathKey));
    const program = ts.createProgram(files, {
        target: ts.ScriptTarget.ES2020,
        module: ts.ModuleKind.ESNext,
        strict: true,
        skipLibCheck: true
    });
    const checker = program.getTypeChecker();
    const functionToType = new Map();
    const typeNodes = new Map();

    for (const sourceFile of program.getSourceFiles()) {
        if (!fileSet.has(normalizePathKey(sourceFile.fileName))) {
            continue;
        }
        indexSourceFile(sourceFile, functionToType, typeNodes);
    }

    state.program = program;
    state.checker = checker;
    state.functionToType = functionToType;
    state.typeNodes = typeNodes;
}

function getYapiFiles() {
    if (!fs.existsSync(YAPI_DIR)) {
        return [];
    }
    return fs
        .readdirSync(YAPI_DIR)
        .filter((file) => file.endsWith('.ts'))
        .filter((file) => !file.includes('request.ts') && !file.includes('makeRequestHook.ts'))
        .map((file) => path.join(YAPI_DIR, file));
}

function indexSourceFile(sourceFile, functionToType, typeNodes) {
    const unwrap = (expr) => {
        let current = expr;
        while (current) {
            if (ts.isParenthesizedExpression(current)) {
                current = current.expression;
                continue;
            }
            if (ts.isAsExpression(current)) {
                current = current.expression;
                continue;
            }
            if (ts.isNonNullExpression(current)) {
                current = current.expression;
                continue;
            }
            if (ts.isTypeAssertionExpression(current)) {
                current = current.expression;
                continue;
            }
            break;
        }
        return current;
    };

    const visit = (node) => {
        if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
            typeNodes.set(node.name.text, node);
        }
        if (ts.isVariableStatement(node) && node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
            for (const decl of node.declarationList.declarations) {
                if (!ts.isIdentifier(decl.name)) {
                    continue;
                }
                const functionName = decl.name.text;
                const initializer = decl.initializer ? unwrap(decl.initializer) : null;
                if (!initializer || (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer))) {
                    continue;
                }
                const responseType = findResponseTypeName(initializer);
                if (responseType) {
                    functionToType.set(functionName, responseType);
                }
            }
        }
        ts.forEachChild(node, visit);
    };
    visit(sourceFile);
}

function findResponseTypeName(func) {
    let result = null;
    const visit = (node) => {
        if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'request') {
            const typeArg = node.typeArguments?.[0];
            if (typeArg && ts.isTypeReferenceNode(typeArg) && ts.isIdentifier(typeArg.typeName)) {
                result = typeArg.typeName.text;
            }
        }
        ts.forEachChild(node, visit);
    };
    if (func.body) {
        ts.forEachChild(func.body, visit);
    }
    return result;
}

function generateMockFromFunction(functionName, mockConfig) {
    ensureProgram();
    const typeName = state.functionToType.get(functionName);
    if (!typeName) {
        return {
            success: true,
            error: { code: '0', message: '' },
            data: {}
        };
    }
    return generateMockFromType(typeName, mockConfig);
}

function generateMockFromType(typeName, mockConfig) {
    ensureProgram();
    const node = state.typeNodes.get(typeName);
    if (!node || !state.checker) {
        return {
            success: true,
            error: { code: '0', message: '' },
            data: {}
        };
    }
    const type = state.checker.getTypeAtLocation(node);
    const mock = buildMockFromType(type, {
        checker: state.checker,
        depth: 0,
        maxDepth: 12,
        path: [],
        mockConfig,
        seen: new Set()
    });
    return mock;
}

function getArrayElementType(checker, arrayType) {
    // Prefer official helper when it works
    const byHelper = checker.getElementTypeOfArrayType(arrayType);
    if (byHelper) {
        return byHelper;
    }
    // Fallback: type arguments for Array<T>
    const args = checker.getTypeArguments(arrayType);
    if (args && args.length > 0) {
        return args[0];
    }
    return null;
}

function getIndexFromPath(pathArr) {
    if (!Array.isArray(pathArr)) {
        return 0;
    }
    for (let i = pathArr.length - 1; i >= 0; i -= 1) {
        const v = pathArr[i];
        if (typeof v === 'string' && /^\d+$/.test(v)) {
            return Number(v);
        }
    }
    return 0;
}

function getParentKey(pathArr) {
    if (!Array.isArray(pathArr)) {
        return '';
    }
    // parent key = nearest non-numeric key before the last key
    for (let i = pathArr.length - 2; i >= 0; i -= 1) {
        const v = pathArr[i];
        if (typeof v === 'string' && !/^\d+$/.test(v)) {
            return v;
        }
    }
    return '';
}

function buildMockFromType(type, context) {
    const { checker, depth, maxDepth, path, mockConfig, seen } = context;
    if (depth > maxDepth) {
        return null;
    }
    const typeId = getTypeId(type);
    if (typeId !== null) {
        if (seen.has(typeId)) {
            return null;
        }
        seen.add(typeId);
    }

    if (type.isUnion()) {
        const next = pickUnionType(type.types);
        return buildMockFromType(next, { ...context, depth: depth + 1 });
    }
    if (type.isIntersection()) {
        const parts = type.types.map((part) => buildMockFromType(part, { ...context, depth: depth + 1 }));
        return parts.reduce((acc, value) => mergeDeep(acc, value), {});
    }
    if (type.isStringLiteral()) {
        return type.value;
    }
    if (type.isNumberLiteral()) {
        return type.value;
    }
    if (type.flags & ts.TypeFlags.BooleanLiteral) {
        return type.intrinsicName === 'true';
    }
    if (type.flags & ts.TypeFlags.String) {
        return createStringSample(path.at(-1), path);
    }
    if (type.flags & ts.TypeFlags.Number) {
        return createNumberSample(path.at(-1), path);
    }
    if (type.flags & ts.TypeFlags.Boolean) {
        return createBooleanSample(path.at(-1), path);
    }
    if (checker.isArrayType(type)) {
        const elementType = getArrayElementType(checker, type);
        if (!elementType) {
            return [];
        }
        const size = getArraySize(path.at(-1), mockConfig);
        // IMPORTANT: do not share `seen` across siblings; arrays should allow repeated element shapes
        return Array.from({ length: size }, (_, index) =>
            buildMockFromType(elementType, {
                ...context,
                depth: depth + 1,
                path: [...path, String(index)],
                seen: new Set(seen)
            })
        );
    }
    if (checker.isTupleType(type)) {
        const tupleElements = checker.getTypeArguments(type);
        return tupleElements.map((item, index) =>
            buildMockFromType(item, { ...context, depth: depth + 1, path: [...path, String(index)], seen: new Set(seen) })
        );
    }
    if (type.flags & ts.TypeFlags.Object) {
        // Handle map-like objects and empty `{}` objects.
        const props = type.getProperties();
        const stringIndexType = typeof type.getStringIndexType === 'function' ? type.getStringIndexType() : null;
        if (props.length === 0) {
            const parentKey = getParentKey(path);
            // i18n-ish objects: assetName/categoryName/promotionActivityTagName etc.
            if (/name|title|label|desc|subtitle|tag/i.test(parentKey)) {
                return {
                    'id-ID': createStringSample('id-ID', [...path, 'id-ID']),
                    'en-US': createStringSample('en-US', [...path, 'en-US']),
                    'zh-CN': createStringSample('zh-CN', [...path, 'zh-CN'])
                };
            }
            // Generic string map
            if (stringIndexType) {
                return {
                    key: buildMockFromType(stringIndexType, {
                        ...context,
                        depth: depth + 1,
                        path: [...path, 'key'],
                        seen: new Set(seen)
                    })
                };
            }
            return {};
        }
        return buildMockFromObject(type, context);
    }
    return null;
}

function buildMockFromObject(type, context) {
    const { checker, depth, maxDepth, path, mockConfig, seen } = context;
    const result = {};
    const props = type.getProperties();
    for (const prop of props) {
        const name = prop.getName();
        const decl = prop.valueDeclaration ?? prop.declarations?.[0];
        const propType = checker.getTypeOfSymbolAtLocation(prop, decl);
        const doc = getSymbolDocText(prop, checker);
        const value = buildMockFromType(propType, {
            checker,
            depth: depth + 1,
            maxDepth,
            path: [...path, name],
            mockConfig,
            seen: new Set(seen)
        });
        result[name] = applyFieldHeuristics(name, value, propType, mockConfig, doc, [...path, name]);
    }
    return result;
}

function applyFieldHeuristics(name, value, propType, mockConfig, doc, pathArr) {
    if (name === 'success' && isBooleanLikeType(propType)) {
        return true;
    }
    if (name === 'error' && isObjectLikeType(propType)) {
        return { code: '0', message: '' };
    }
    // common pagination flags
    if (name === 'hasMore' && isBooleanLikeType(propType)) {
        const total = typeof mockConfig?.total === 'number' ? mockConfig.total : 0;
        const pageSize = typeof mockConfig?.pageSize === 'number' ? mockConfig.pageSize : 0;
        if (total > 0 && pageSize > 0) {
            return total > pageSize;
        }
        return false;
    }

    // Use doc hint for status enum
    if (name === 'status' && typeof value === 'string') {
        if (typeof doc === 'string' && /valid=.*used=.*locked=.*expired=.*not_effective=/i.test(doc)) {
            return 'valid';
        }
        return 'valid';
    }

    // common enum-like fields by business meaning
    if (name === 'status' && typeof value === 'string') {
        return 'valid';
    }
    if (name === 'category' && typeof value === 'string') {
        return 'COUPON';
    }
    if (name === 'couponType' && typeof value === 'string') {
        return 'discount';
    }
    if (name === 'redeemTimeType' && typeof value === 'string') {
        return 'OPENING_TIME';
    }

    // fields that represent "used" info should be empty for valid coupons
    if ((name === 'usedStoreId' || name === 'usedStoreName' || name === 'usedTime') && typeof value === 'string') {
        return '';
    }
    // opening time mode does not need a fixed time range
    if (name === 'redeemTimes' && typeof value === 'string') {
        return '';
    }

    // number business meanings
    if (name === 'couponAmount' && typeof value === 'number') {
        // If doc says discount/deduct, default discount 10%
        return 10;
    }
    if (name === 'useOrderAmountThreshold' && typeof value === 'number') {
        return 50000;
    }
    if (name === 'effectiveDays' && typeof value === 'number') {
        return 30;
    }
    if (name === 'remainingDays' && typeof value === 'number') {
        return 7;
    }
    if (name === 'orderUseLimitNum' && typeof value === 'number') {
        return 1;
    }

    // Time fields: try to be coherent
    if (typeof value === 'string' && /time/i.test(name)) {
        // for "receivedTime": now - minutes by index
        const idx = getIndexFromPath(pathArr);
        if (name === 'receivedTime') return new Date(Date.now() - idx * 2 * 60_000).toISOString();
        if (name === 'effectiveStartTime') return new Date(Date.now()).toISOString();
        if (name === 'effectiveEndTime') return new Date(Date.now() + 30 * 24 * 60 * 60_000).toISOString();
    }

    if (name === 'total' && typeof mockConfig?.total === 'number') {
        return mockConfig.total;
    }
    if (name === 'pageSize' && typeof mockConfig?.pageSize === 'number') {
        return mockConfig.pageSize;
    }

    // If doc hints "券id/模版id" etc, make them stable
    if (typeof value === 'number') {
        const idx = getIndexFromPath(pathArr);
        if (typeof doc === 'string') {
            if (/券id/i.test(doc) || name === 'id') return idx + 1;
            if (/模版id/i.test(doc) || name === 'templateId') return 10000 + (idx + 1);
        }
    }

    return value;
}

function isBooleanLikeType(type) {
    if (type.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) {
        return true;
    }
    if (type.isUnion()) {
        return type.types.some((t) => isBooleanLikeType(t));
    }
    return false;
}

function isObjectLikeType(type) {
    if (type.flags & ts.TypeFlags.Object) {
        return true;
    }
    if (type.isUnion()) {
        return type.types.some((t) => isObjectLikeType(t));
    }
    return false;
}

function pickUnionType(types) {
    const preferred = types.find((item) => !isNullableType(item));
    return preferred || types[0];
}

function isNullableType(type) {
    return Boolean(type.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined));
}

function getArraySize(name, mockConfig) {
    // Lists: should respect listSize/pageSize; other arrays should stay small
    const fieldName = String(name || '');
    const isListField = /list|records|items|couponList|orderList/i.test(fieldName);
    const isIdsField = /Ids$/i.test(fieldName);
    if (isListField) {
        if (typeof mockConfig?.listSize === 'number') {
            return Math.max(mockConfig.listSize, 0);
        }
        if (typeof mockConfig?.pageSize === 'number') {
            return Math.max(mockConfig.pageSize, 0);
        }
        return 1;
    }

    // Id arrays (e.g. scopeStoreIds) are usually small
    if (isIdsField) {
        return 2;
    }

    // Non-list arrays: keep small and stable
    if (typeof mockConfig?.listSize === 'number' && mockConfig.listSize > 0) {
        return Math.min(2, mockConfig.listSize);
    }
    return 2;
}

function createStringSample(name, pathArr) {
    if (!name) {
        return '';
    }
    const index = getIndexFromPath(pathArr);
    const field = String(name);
    const parentKey = getParentKey(pathArr);

    // Array element (name is "0"/"1"...): use parent field semantics
    if (/^\d+$/.test(field)) {
        if (/scopeStoreIds/i.test(parentKey)) {
            return `store-${String(index + 1).padStart(3, '0')}`;
        }
        if (/storeIds/i.test(parentKey)) {
            return `store-${String(index + 1).padStart(3, '0')}`;
        }
        if (/scope/i.test(parentKey) && /Ids$/i.test(parentKey)) {
            return String(index + 1);
        }
        return `item-${index + 1}`;
    }

    // localized strings
    if (field === 'id-ID') {
        if (/couponLabel/i.test(parentKey)) return 'Diskon';
        if (/couponSubTitle/i.test(parentKey)) return `Diskon 10% min. Rp 50.000`;
        if (/description/i.test(parentKey)) return `Kupon diskon 10% untuk pembelian makanan`;
        if (/name/i.test(parentKey)) return `Kupon ${index + 1}`;
        return `Teks ${index + 1}`;
    }
    if (field === 'en-US') {
        if (/couponLabel/i.test(parentKey)) return 'Discount';
        if (/couponSubTitle/i.test(parentKey)) return `10% off, min Rp 50,000`;
        if (/description/i.test(parentKey)) return `10% discount coupon for food purchases`;
        if (/name/i.test(parentKey)) return `Coupon ${index + 1}`;
        return `Text ${index + 1}`;
    }
    if (field === 'zh-CN') {
        if (/couponLabel/i.test(parentKey)) return '折扣';
        if (/couponSubTitle/i.test(parentKey)) return `满50000印尼盾享10%折扣`;
        if (/description/i.test(parentKey)) return `食品购买10%折扣券`;
        if (/name/i.test(parentKey)) return `优惠券${index + 1}`;
        return `文本${index + 1}`;
    }

    // code-like
    if (/couponCode/i.test(field)) {
        return `CPN-${String(index + 1).padStart(4, '0')}`;
    }

    // url-like
    // Always return a working image URL for image resources.
    if (/url$/i.test(field) || /logoUrl/i.test(field) || /image/i.test(field)) {
        return DEFAULT_MOCK_IMAGE_URL;
    }

    // geo / address / distance (often string in payload)
    if (/latitude/i.test(field)) return '-6.200000';
    if (/longitude/i.test(field)) return '106.816666';
    if (/distance/i.test(field)) return `${(index + 1) * 0.3}km`;
    if (/address/i.test(field)) return `Jl. Sudirman No. ${index + 1}`;
    if (/province/i.test(field)) return 'DKI Jakarta';
    if (/city/i.test(field)) return 'Jakarta';
    if (/area/i.test(field) || /district/i.test(field)) return 'Central Jakarta';

    // store ids / names
    if (/storeId$/i.test(field)) {
        return `store-${String(index + 1).padStart(3, '0')}`;
    }
    if (/storeName$/i.test(field) || /applicableStoreName/i.test(field)) {
        return `Store ${index + 1}`;
    }

    // product/asset fields
    if (/assetType/i.test(field)) return 'PRODUCT';
    if (/remainingStock/i.test(field)) return String(100 - index);
    if (/discountAmount/i.test(field)) return 'Rp 10.000';
    if (/assetType/i.test(field)) return 'PRODUCT';

    if (/id$/i.test(field)) {
        // generic id should look like an id, not "mock-id"
        return String(index + 1);
    }
    if (/code/i.test(name)) {
        return `CODE-${String(index + 1).padStart(4, '0')}`;
    }
    if (/time|date/i.test(field)) {
        return new Date(Date.now() - index * 60_000).toISOString();
    }
    if (/ruleDescription|useRuleDescription/i.test(field)) {
        return 'Minimum purchase Rp 50,000';
    }
    if (/name/i.test(field)) {
        return `${field} ${index + 1}`;
    }
    if (/description|title|subtitle/i.test(field)) {
        return `${field} text ${index + 1}`;
    }
    // Generic non-empty fallback for unknown string fields
    return `${field}-${index + 1}`;
}

function getSymbolDocText(symbol, checker) {
    try {
        const parts = symbol.getDocumentationComment(checker);
        const text = ts.displayPartsToString(parts);
        return (text || '').trim();
    } catch {
        return '';
    }
}

function createNumberSample(name, pathArr) {
    if (!name) {
        return 1;
    }
    const index = getIndexFromPath(pathArr);
    const field = String(name);
    if (/id$/i.test(field)) {
        return index + 1;
    }
    if (/percent/i.test(field)) return 10;
    if (/ratio/i.test(field)) return 0.1;
    if (/amount|price|fee/i.test(field)) {
        return 1000;
    }
    if (/threshold/i.test(field)) {
        return 50000;
    }
    return 0;
}

function createBooleanSample(name, pathArr) {
    const field = String(name || '');
    if (field === 'hasMore') return false;
    if (/is|has|enable|visible|valid/i.test(field)) return true;
    // stable alternating for list items
    const index = getIndexFromPath(pathArr);
    return index % 2 === 0;
}

function getTypeId(type) {
    const candidate = type;
    if (candidate && typeof candidate === 'object' && 'id' in candidate) {
        const id = candidate.id;
        return typeof id === 'number' ? id : null;
    }
    return null;
}

function mergeDeep(target, source) {
    if (!source || typeof source !== 'object') {
        return target ?? source;
    }
    if (Array.isArray(source)) {
        return source.slice();
    }
    const output = isPlainObject(target) ? { ...target } : {};
    for (const [key, value] of Object.entries(source)) {
        if (isPlainObject(value)) {
            output[key] = mergeDeep(output[key], value);
        } else {
            output[key] = value;
        }
    }
    return output;
}

function isPlainObject(value) {
    return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

function parseCliArgs(argv) {
    const out = { functionName: null, pageSize: null, total: null, outFile: null };
    for (let i = 0; i < argv.length; i += 1) {
        const arg = argv[i];
        const next = argv[i + 1];
        if (arg === '--functionName' && next) {
            out.functionName = next;
            i += 1;
            continue;
        }
        if (arg === '--pageSize' && next) {
            out.pageSize = Number(next);
            i += 1;
            continue;
        }
        if (arg === '--total' && next) {
            out.total = Number(next);
            i += 1;
            continue;
        }
        if (arg === '--out' && next) {
            out.outFile = next;
            i += 1;
            continue;
        }
    }
    return out;
}

function runCliIfNeeded() {
    const cli = parseCliArgs(process.argv.slice(2));
    if (!cli.functionName) {
        return false;
    }
    const size = Number.isFinite(cli.pageSize) ? cli.pageSize : Number.isFinite(cli.total) ? cli.total : 1;
    const mock = generateMockFromFunction(cli.functionName, {
        pageSize: size,
        total: Number.isFinite(cli.total) ? cli.total : undefined,
        listSize: size
    });
    const text = JSON.stringify(mock, null, 2);
    if (cli.outFile) {
        const outPath = path.isAbsolute(cli.outFile) ? cli.outFile : path.join(projectRoot, cli.outFile);
        fs.writeFileSync(outPath, text, 'utf8');
    } else {
        console.log(text);
    }
    return true;
}

if (!runCliIfNeeded()) {
    startServer().catch((error) => {
        console.error('[mcp-mock] failed to start:', error);
        process.exit(1);
    });
}


package.json配置命令

 "mcp:mock": "node scripts/mcp-mock-server/index.cjs"

配置mcp

  "yapi-mock-generator": {
      "command": "node",
      "args": ["D:\\app\\scripts\\mcp-mock-server\\index.cjs"],
      "cwd": "D:\\app",
      "env": {
        "YAPI_DIR": "D:\\app\\apps\\capacitor\\src\\yapi",
        "NODE_PATH": "D:\\app\\node_modules"
      }
    }

向cursor发起对话,则会在当前目录生成更接近真实的mock数据,