diff --git a/packages/apifox-mcp/src/api.ts b/packages/apifox-mcp/src/api.ts index 7759b7d..c123c35 100644 --- a/packages/apifox-mcp/src/api.ts +++ b/packages/apifox-mcp/src/api.ts @@ -14,6 +14,7 @@ export interface ApifoxApiOptions { apiVersion?: string; apiPageSize?: number; dataLocation?: string; + locale?: string; } export type ApifoxConfigKey = keyof ApifoxApiOptions; @@ -27,6 +28,7 @@ export interface ResolvedApifoxApiOptions { apiVersion: string; apiPageSize: number; dataLocation: string; + locale?: string; } export type ApifoxSource = @@ -43,12 +45,50 @@ export interface CacheInfo { lastUpdatedAt?: string; } -type OpenApiDocument = JsonObject & { +export type FetchProjectDocumentScope = + | { + type?: "ALL"; + excludedByTags?: string[]; + } + | { + type: "SELECTED_TAGS"; + selectedTags: string[]; + excludedByTags?: string[]; + } + | { + type: "SELECTED_FOLDERS"; + selectedFolderIds: number[]; + excludedByTags?: string[]; + } + | { + type: "SELECTED_ENDPOINTS"; + selectedEndpointIds: number[]; + excludedByTags?: string[]; + }; + +export interface FetchProjectDocumentRequestOptions { + includeApifoxExtensionProperties?: boolean; + addFoldersToTags?: boolean; +} + +export interface FetchProjectDocumentOptions { + scope?: FetchProjectDocumentScope; + options?: FetchProjectDocumentRequestOptions; + oasVersion?: string; + exportFormat?: string; + environmentIds?: number[]; + branchId?: number; + moduleId?: number; +} + +export type OpenApiDocument = JsonObject & { paths?: Record; components?: Record>; info?: JsonObject; }; +export type ProjectDocument = OpenApiDocument | string; + const DEFAULT_API_BASE_URL = "https://api.apifox.com"; const DEFAULT_API_VERSION = "2024-03-28"; const DEFAULT_API_PAGE_SIZE = Number.MAX_SAFE_INTEGER; @@ -64,6 +104,7 @@ export const APIFOX_CONFIG_KEYS = [ "apiVersion", "apiPageSize", "dataLocation", + "locale", ] as const satisfies readonly ApifoxConfigKey[]; function isRecord(value: unknown): value is JsonObject { @@ -194,6 +235,10 @@ export function resolveApifoxInputOptions(options: ApifoxApiOptions): ApifoxApiO normalizeOptionalString(options.dataLocation) ?? (typeof config.dataLocation === "string" ? config.dataLocation : undefined) ?? normalizeOptionalString(process.env.APIFOX_DATA_LOCATION), + locale: + normalizeOptionalString(options.locale) ?? + (typeof config.locale === "string" ? config.locale : undefined) ?? + normalizeOptionalString(process.env.APIFOX_LOCALE), }; } @@ -211,6 +256,7 @@ export function resolveApifoxEffectiveOptionValues( apiVersion: resolvedInputs.apiVersion ?? DEFAULT_API_VERSION, apiPageSize: normalizePageSize(resolvedInputs.apiPageSize), dataLocation: resolvedInputs.dataLocation ?? os.homedir(), + locale: resolvedInputs.locale, }; } @@ -401,6 +447,7 @@ export function resolveApifoxApiOptions(options: ApifoxApiOptions): ResolvedApif apiVersion: resolvedInputs.apiVersion ?? DEFAULT_API_VERSION, apiPageSize: normalizePageSize(resolvedInputs.apiPageSize), dataLocation: resolvedInputs.dataLocation ?? os.homedir(), + locale: resolvedInputs.locale, }; } @@ -440,7 +487,7 @@ export class ApifoxAPI { this.cacheDir = path.join( this.options.dataLocation, DEFAULT_DATA_ROOT_NAME, - this.getSourceCacheKey(), + this.getSourceCacheKey(this.options.source), ); this.cacheFile = path.join(this.cacheDir, "index.json"); } @@ -514,8 +561,7 @@ export class ApifoxAPI { } } - private getSourceCacheKey(): string { - const { source } = this.options; + private getSourceCacheKey(source: ApifoxSource): string { switch (source.name) { case "project": return `project-${source.identifier}`; @@ -529,10 +575,10 @@ export class ApifoxAPI { } } - private async requestJson(url: string, init?: RequestInit): Promise { + private buildRequestHeaders(init?: RequestInit): Headers { const headers = new Headers(init?.headers); headers.set("User-Agent", "apifox-mcp-server/0.0.1"); - headers.set("X-Apifox-Version", this.options.apiVersion); + headers.set("X-Apifox-Api-Version", this.options.apiVersion); if (this.options.token) { headers.set("Authorization", `Bearer ${this.options.token}`); @@ -542,6 +588,12 @@ export class ApifoxAPI { headers.set("Content-Type", "application/json"); } + return headers; + } + + private async requestJson(url: string, init?: RequestInit): Promise { + const headers = this.buildRequestHeaders(init); + const response = await fetch(url, { ...init, headers, @@ -557,6 +609,28 @@ export class ApifoxAPI { return response.json(); } + private async requestBody(url: string, init?: RequestInit): Promise { + const headers = this.buildRequestHeaders(init); + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `请求失败: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + + const contentType = response.headers.get("Content-Type") ?? ""; + if (contentType.includes("json")) { + return response.json(); + } + return response.text(); + } + private async fetchSourceDocument(): Promise { const { source } = this.options; @@ -565,7 +639,7 @@ export class ApifoxAPI { if (!this.options.token) { throw new Error("读取 Apifox 项目时必须提供 APIFOX_ACCESS_TOKEN"); } - return this.fetchProjectDocument(source.identifier); + return this.assertOpenApi(await this.fetchProjectDocument(source.identifier)); case "doc-site": return this.fetchDocSiteDocument(source.identifier); case "remote-file": @@ -577,18 +651,71 @@ export class ApifoxAPI { } } - private async fetchProjectDocument(projectId: string): Promise { - const url = new URL( - `/api/v1/projects/${projectId}/export-openapi`, - this.options.apiBaseUrl, - ).toString(); - const result = await this.requestJson(url, { + async fetchProjectDocument( + projectId: string, + options: FetchProjectDocumentOptions = {}, + ): Promise { + const url = new URL(`/v1/projects/${projectId}/export-openapi`, this.options.apiBaseUrl); + if (this.options.locale) { + url.searchParams.set("locale", this.options.locale); + } + + const result = await this.requestBody(url.toString(), { method: "POST", - body: JSON.stringify({ scope: { type: "ALL" } }), + body: JSON.stringify(this.buildProjectRequestBody(options)), }); + if (typeof result === "string") { + return result; + } return this.assertOpenApi(result); } + private buildProjectRequestBody(options: FetchProjectDocumentOptions): JsonObject { + const { + branchId, + environmentIds, + exportFormat, + moduleId, + oasVersion, + options: requestOptions, + } = options; + + const body: JsonObject = { + scope: options.scope ?? { type: "ALL" }, + }; + + if ( + requestOptions?.includeApifoxExtensionProperties !== undefined || + requestOptions?.addFoldersToTags !== undefined + ) { + body.options = { + ...(requestOptions.includeApifoxExtensionProperties !== undefined + ? { includeApifoxExtensionProperties: requestOptions.includeApifoxExtensionProperties } + : {}), + ...(requestOptions.addFoldersToTags !== undefined + ? { addFoldersToTags: requestOptions.addFoldersToTags } + : {}), + }; + } + + if (oasVersion) { + body.oasVersion = oasVersion; + } + if (exportFormat) { + body.exportFormat = exportFormat; + } + if (branchId !== undefined) { + body.branchId = branchId; + } + if (moduleId !== undefined) { + body.moduleId = moduleId; + } + if (environmentIds && environmentIds.length > 0) { + body.environmentIds = environmentIds; + } + + return body; + } private async fetchDocSiteDocument(siteId: string): Promise { const url = new URL( `/api/v1/docs-sites/${siteId}/export-mcp-data`, diff --git a/packages/apifox-mcp/src/cli.ts b/packages/apifox-mcp/src/cli.ts index 83ce827..8add6a0 100644 --- a/packages/apifox-mcp/src/cli.ts +++ b/packages/apifox-mcp/src/cli.ts @@ -12,6 +12,7 @@ import { resolveApifoxEffectiveOptionValues, writeApifoxConfig, type ApifoxApiOptions, + type FetchProjectDocumentScope, } from "./api.js"; dotenv.config({ quiet: true }); @@ -28,6 +29,41 @@ function getNumber(args: CommandArgs, key: string): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function getBoolean(args: CommandArgs, key: string): boolean | undefined { + const value = args[key]; + return typeof value === "boolean" ? value : undefined; +} + +function getStringArray(args: CommandArgs, key: string): string[] | undefined { + const value = args[key]; + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value.flatMap((item) => + item + .split(",") + .map((nestedItem) => nestedItem.trim()) + .filter((nestedItem) => nestedItem.length > 0), + ); + } + return undefined; +} + +function getNumberArray(args: CommandArgs, key: string): number[] | undefined { + const values = getStringArray(args, key); + if (!values) { + return undefined; + } + const numberValues = values + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value)); + return numberValues.length > 0 ? numberValues : undefined; +} + function getRequiredString(args: CommandArgs, key: string): string { const value = getString(args, key); if (!value) { @@ -42,6 +78,24 @@ function addConnectionOptions(parser: Argv): Argv { type: "string", describe: "Access Token;", }) + .option("apiBaseUrl", { + type: "string", + alias: "apifox-api-base-url", + describe: "API 基础地址;", + }) + .option("apiVersion", { + type: "string", + alias: "api-version", + describe: "API 版本头;", + }) + .option("locale", { + type: "string", + describe: "locale 查询参数;", + }); +} + +function addSourceOptions(parser: Argv): Argv { + return parser .option("projectId", { type: "string", alias: "project-id", @@ -55,27 +109,25 @@ function addConnectionOptions(parser: Argv): Argv { .option("oas", { type: "string", describe: "远程或本地 OpenAPI 文件地址;与 --projectId、--siteId 三选一显式传入", - }) - .option("apiBaseUrl", { - type: "string", - alias: "apifox-api-base-url", - describe: "API 基础地址;", - }) - .option("apiVersion", { - type: "string", - alias: "api-version", - describe: "API 版本头;", - }) - .option("apiPageSize", { + }); +} + +function addCacheOptions(parser: Argv): Argv { + return parser.option("dataLocation", { + type: "string", + alias: "data-location", + describe: "缓存根目录;", + }); +} + +function addOasCacheOptions(parser: Argv): Argv { + return addCacheOptions( + parser.option("apiPageSize", { type: "number", alias: "api-page-size", describe: "paths 拆分页大小;", - }) - .option("dataLocation", { - type: "string", - alias: "data-location", - describe: "缓存根目录;", - }); + }), + ); } function getApifoxCliOptions(args: CommandArgs): ApifoxApiOptions { @@ -88,6 +140,7 @@ function getApifoxCliOptions(args: CommandArgs): ApifoxApiOptions { apiVersion: getString(args, "apiVersion"), apiPageSize: getNumber(args, "apiPageSize"), dataLocation: getString(args, "dataLocation"), + locale: getString(args, "locale"), }; } @@ -95,29 +148,83 @@ function getClient(args: CommandArgs): ApifoxAPI { return new ApifoxAPI(getApifoxCliOptions(args)); } -function printJsonResult(result: unknown): void { +function printResult(result: unknown): void { + if (typeof result === "string") { + console.log(result); + return; + } console.log(JSON.stringify(result, null, 2)); } +function buildProjectScope(args: CommandArgs): FetchProjectDocumentScope { + const excludedByTags = getStringArray(args, "excludedByTags"); + const selectedTags = getStringArray(args, "selectedTags"); + const selectedFolderIds = getNumberArray(args, "selectedFolderIds"); + const selectedEndpointIds = getNumberArray(args, "selectedEndpointIds"); + const selectedScopeCount = [ + selectedTags?.length, + selectedFolderIds?.length, + selectedEndpointIds?.length, + ].filter((length) => length !== undefined && length > 0).length; + + if (selectedScopeCount > 1) { + throw new Error( + "--selected-tags、--selected-folder-ids、--selected-endpoint-ids 只能同时使用一种", + ); + } + + if (selectedTags && selectedTags.length > 0) { + return { + type: "SELECTED_TAGS", + selectedTags, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + if (selectedFolderIds && selectedFolderIds.length > 0) { + return { + type: "SELECTED_FOLDERS", + selectedFolderIds, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + if (selectedEndpointIds && selectedEndpointIds.length > 0) { + return { + type: "SELECTED_ENDPOINTS", + selectedEndpointIds, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + throw new Error( + "通过 project 读取时必须指定 --selected-tags、--selected-folder-ids 或 --selected-endpoint-ids 之一", + ); +} + function registerOasCommands(parser: Argv): Argv { return parser.command( "oas ", "OpenAPI 文档操作:view / refresh;", (command) => - command.positional("action", { - choices: ["view", "refresh"] as const, - describe: "操作类型", - }), + addOasCacheOptions( + addSourceOptions( + command.positional("action", { + choices: ["view", "refresh"] as const, + describe: "操作类型", + }), + ), + ), async (args: CommandArgs) => { const client = getClient(args); const action = getRequiredString(args, "action"); switch (action) { case "view": - printJsonResult(JSON.parse(await client.readProjectOas()) as unknown); + printResult(JSON.parse(await client.readProjectOas()) as unknown); return; case "refresh": - printJsonResult(await client.refreshProjectOas()); + printResult(await client.refreshProjectOas()); return; default: throw new Error(`未知操作类型: ${action}`); @@ -126,21 +233,125 @@ function registerOasCommands(parser: Argv): Argv { ); } -function registerRefCommands(parser: Argv): Argv { +function registerProjectCommands(parser: Argv): Argv { return parser.command( - "refs ", - "引用资源操作:read;", + "project ", + "实时读取 Apifox 项目 OpenAPI 数据;不经过 OAS 缓存", (command) => command - .positional("action", { - choices: ["read"] as const, - describe: "操作类型", + .positional("id", { + type: "string", + describe: "Apifox 项目 ID", + }) + .option("excludedByTags", { + type: "array", + string: true, + alias: "excluded-by-tags", + describe: "排除的标签;可重复传入或用逗号分隔;默认不排除", + }) + .option("selectedTags", { + type: "array", + string: true, + alias: "selected-tags", + describe: + "只返回指定标签;可重复传入或用逗号分隔;与 --selected-folder-ids、--selected-endpoint-ids 三选一必填", + }) + .option("selectedFolderIds", { + type: "array", + string: true, + alias: "selected-folder-ids", + describe: + "只返回指定目录 ID;可重复传入或用逗号分隔;与 --selected-tags、--selected-endpoint-ids 三选一必填", + }) + .option("selectedEndpointIds", { + type: "array", + string: true, + alias: "selected-endpoint-ids", + describe: + "只返回指定接口 ID;可重复传入或用逗号分隔;与 --selected-tags、--selected-folder-ids 三选一必填", + }) + .option("includeApifoxExtensionProperties", { + type: "boolean", + alias: "include-apifox-extension-properties", + describe: "是否包含 Apifox 扩展属性;可选 true/false;默认 false", + }) + .option("addFoldersToTags", { + type: "boolean", + alias: "add-folders-to-tags", + describe: "是否将目录加入 tags;可选 true/false;默认 false", + }) + .option("oasVersion", { + type: "string", + alias: "oas-version", + choices: ["2.0", "3.0", "3.1"] as const, + describe: "OAS 版本;可选 2.0/3.0/3.1;默认 3.1", + }) + .option("exportFormat", { + type: "string", + alias: "export-format", + choices: ["JSON", "YAML"] as const, + describe: "返回格式;可选 JSON/YAML;默认 JSON", + }) + .option("branchId", { + type: "number", + alias: "branch-id", + describe: "分支 ID;默认不传,读取当前默认分支", + }) + .option("moduleId", { + type: "number", + alias: "module-id", + describe: "模块 ID;默认不传,读取全部模块", }) - .option("path", { + .option("environmentIds", { type: "array", string: true, - describe: "一个或多个 $ref 文件路径,可重复传入", + alias: "environment-ids", + describe: "环境 ID;可重复传入或用逗号分隔;默认不传", }), + async (args: CommandArgs) => { + const projectId = getRequiredString(args, "id"); + const client = getClient({ + ...args, + projectId, + }); + + printResult( + await client.fetchProjectDocument(projectId, { + branchId: getNumber(args, "branchId"), + moduleId: getNumber(args, "moduleId"), + environmentIds: getNumberArray(args, "environmentIds"), + scope: buildProjectScope(args), + options: { + includeApifoxExtensionProperties: getBoolean(args, "includeApifoxExtensionProperties"), + addFoldersToTags: getBoolean(args, "addFoldersToTags"), + }, + oasVersion: getString(args, "oasVersion"), + exportFormat: getString(args, "exportFormat"), + }), + ); + }, + ); +} + +function registerRefCommands(parser: Argv): Argv { + return parser.command( + "refs ", + "引用资源操作:read;", + (command) => + addCacheOptions( + addSourceOptions( + command + .positional("action", { + choices: ["read"] as const, + describe: "操作类型", + }) + .option("path", { + type: "array", + string: true, + describe: "一个或多个 $ref 文件路径,可重复传入", + }), + ), + ), async (args: CommandArgs) => { const client = getClient(args); const action = getRequiredString(args, "action"); @@ -151,7 +362,7 @@ function registerRefCommands(parser: Argv): Argv { if (!Array.isArray(paths) || paths.some((item) => typeof item !== "string")) { throw new Error("缺少必要参数: path"); } - printJsonResult(await client.readProjectOasRefResources(paths as string[])); + printResult(await client.readProjectOasRefResources(paths as string[])); return; } default: @@ -166,17 +377,21 @@ function registerCacheCommands(parser: Argv): Argv { "cache ", "缓存操作:info;返回缓存路径、来源与最后更新时间", (command) => - command.positional("action", { - choices: ["info"] as const, - describe: "操作类型", - }), + addCacheOptions( + addSourceOptions( + command.positional("action", { + choices: ["info"] as const, + describe: "操作类型", + }), + ), + ), async (args: CommandArgs) => { const client = getClient(args); const action = getRequiredString(args, "action"); switch (action) { case "info": - printJsonResult(await client.getResolvedCacheInfo()); + printResult(await client.getResolvedCacheInfo()); return; default: throw new Error(`未知操作类型: ${action}`); @@ -245,6 +460,7 @@ async function runCli(): Promise { parser = registerOasCommands(parser); parser = registerRefCommands(parser); parser = registerCacheCommands(parser); + parser = registerProjectCommands(parser); parser = registerConfigCommands(parser); await parser diff --git a/packages/apifox-mcp/src/index.ts b/packages/apifox-mcp/src/index.ts index 8b4ad07..30e87ce 100644 --- a/packages/apifox-mcp/src/index.ts +++ b/packages/apifox-mcp/src/index.ts @@ -16,6 +16,7 @@ import { resolveApifoxInputOptions, type ApifoxApiOptions, type ApifoxSource, + type FetchProjectDocumentScope, } from "./api.js"; export { ApifoxAPI } from "./api.js"; @@ -29,6 +30,7 @@ interface ToolNames { readOas: string; readRefs: string; refreshOas: string; + readProject: string; cacheInfo: string; } @@ -53,6 +55,62 @@ function getOptionalNumber(args: CommandArgs, key: string): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function getBoolean(args: CommandArgs, key: string): boolean | undefined { + const value = args[key]; + return typeof value === "boolean" ? value : undefined; +} + +function getStringArray(args: CommandArgs, key: string): string[] | undefined { + const value = args[key]; + if (typeof value === "string") { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return value.flatMap((item) => + item + .split(",") + .map((nestedItem) => nestedItem.trim()) + .filter((nestedItem) => nestedItem.length > 0), + ); + } + return undefined; +} + +function getNumberArray(args: CommandArgs, key: string): number[] | undefined { + const value = args[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return [value]; + } + + const values = getStringArray(args, key); + if (values) { + const numberValues = values + .map((item) => Number.parseInt(item, 10)) + .filter((item) => Number.isInteger(item)); + return numberValues.length > 0 ? numberValues : undefined; + } + + if (Array.isArray(value)) { + const numberValues = value + .map((item) => { + if (typeof item === "number") { + return item; + } + if (typeof item === "string") { + return Number.parseInt(item, 10); + } + return Number.NaN; + }) + .filter((item) => Number.isInteger(item)); + return numberValues.length > 0 ? numberValues : undefined; + } + + return undefined; +} + function addConnectionOptions(parser: ReturnType): ReturnType { return parser .option("token", { @@ -62,16 +120,16 @@ function addConnectionOptions(parser: ReturnType): ReturnType): ReturnType length !== undefined && length > 0).length; + + if (selectedScopeCount > 1) { + throw new Error("selectedTags、selectedFolderIds、selectedEndpointIds 只能同时使用一种"); + } + + if (selectedTags && selectedTags.length > 0) { + return { + type: "SELECTED_TAGS", + selectedTags, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + if (selectedFolderIds && selectedFolderIds.length > 0) { + return { + type: "SELECTED_FOLDERS", + selectedFolderIds, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + if (selectedEndpointIds && selectedEndpointIds.length > 0) { + return { + type: "SELECTED_ENDPOINTS", + selectedEndpointIds, + ...(excludedByTags ? { excludedByTags } : {}), + }; + } + + throw new Error( + "通过 project 读取时必须指定 selectedTags、selectedFolderIds 或 selectedEndpointIds 之一", + ); +} + function createSourceSchemaProperties() { return { projectId: { type: "string", - description: "Apifox 项目 ID;当服务启动时未传入来源参数时,与 siteId、oas 三选一", + description: "项目 ID;与 siteId、oas 三选一显式传入", }, siteId: { type: "string", - description: "Apifox 文档站点 ID;当服务启动时未传入来源参数时,与 projectId、oas 三选一", + description: "文档站点 ID;与 projectId、oas 三选一显式传入", }, oas: { type: "string", + description: "远程或本地 OpenAPI 文件地址;与 projectId、siteId 三选一显式传入", + }, + }; +} + +function createProjectIdSchemaProperties(startupOptions: ApifoxApiOptions): Record { + if (startupOptions.projectId) { + return {}; + } + + return { + projectId: { + type: "string", + description: "Apifox 项目 ID", + }, + }; +} + +function createProjectDocumentSchemaProperties(startupOptions: ApifoxApiOptions) { + return { + ...createProjectIdSchemaProperties(startupOptions), + branchId: { + type: "number", + description: "分支 ID;默认不传,读取当前默认分支", + }, + moduleId: { + type: "number", + description: "模块 ID;默认不传,读取全部模块", + }, + environmentIds: { + type: "array", + items: { type: "number" }, + description: "环境 ID;可重复传入或用逗号分隔;默认不传", + }, + excludedByTags: { + type: "array", + items: { type: "string" }, + description: "排除的标签;可重复传入或用逗号分隔;默认不排除", + }, + selectedTags: { + type: "array", + items: { type: "string" }, description: - "远程或本地 OpenAPI 文件地址;当服务启动时未传入来源参数时,与 projectId、siteId 三选一", + "只返回指定标签;可重复传入或用逗号分隔;与 selectedFolderIds、selectedEndpointIds 三选一必填", + }, + selectedFolderIds: { + type: "array", + items: { type: "number" }, + description: + "只返回指定目录 ID;可重复传入或用逗号分隔;与 selectedTags、selectedEndpointIds 三选一必填", + }, + selectedEndpointIds: { + type: "array", + items: { type: "number" }, + description: + "只返回指定接口 ID;可重复传入或用逗号分隔;与 selectedTags、selectedFolderIds 三选一必填", + }, + includeApifoxExtensionProperties: { + type: "boolean", + description: "是否包含 Apifox 扩展属性;可选 true/false;默认 false", + }, + addFoldersToTags: { + type: "boolean", + description: "是否将目录加入 tags;可选 true/false;默认 false", + }, + oasVersion: { + type: "string", + enum: ["2.0", "3.0", "3.1"], + description: "OAS 版本;可选 2.0/3.0/3.1;默认 3.1", + }, + exportFormat: { + type: "string", + enum: ["JSON", "YAML"], + description: "返回格式;可选 JSON/YAML;默认 JSON", }, }; } +const projectScopeOneOf = [ + { required: ["selectedTags"] }, + { required: ["selectedFolderIds"] }, + { required: ["selectedEndpointIds"] }, +]; + function getSourceLabel(source: ApifoxSource): string { switch (source.name) { case "project": @@ -200,6 +391,7 @@ function buildToolNames(startupOptions: ApifoxApiOptions): ToolNames { readRefs: "read_apifox_oas_ref_resources", refreshOas: "refresh_apifox_oas", cacheInfo: "get_apifox_cache_info", + readProject: "read_apifox_project", }; } @@ -209,6 +401,7 @@ function buildToolNames(startupOptions: ApifoxApiOptions): ToolNames { readRefs: `read_apifox_oas_ref_resources_${suffix}`, refreshOas: `refresh_apifox_oas_${suffix}`, cacheInfo: `get_apifox_cache_info_${suffix}`, + readProject: `read_apifox_project_${suffix}`, }; } @@ -237,6 +430,7 @@ async function buildToolMetadata( const baseOneOf = needsProjectId ? [{ required: ["projectId"] }, { required: ["siteId"] }, { required: ["oas"] }] : undefined; + const projectRequired = startupOptions.projectId ? [] : ["projectId"]; return { names, @@ -289,10 +483,29 @@ async function buildToolMetadata( ...(baseOneOf ? { oneOf: baseOneOf } : {}), }, }, + { + name: names.readProject, + description: + "实时读取 Apifox 项目 OpenAPI 数据;不经过 OAS 缓存;必须提供一个 scope:selectedTags、selectedFolderIds 或 selectedEndpointIds 三选一", + inputSchema: { + type: "object", + properties: createProjectDocumentSchemaProperties(startupOptions), + required: projectRequired, + oneOf: projectScopeOneOf, + }, + }, ], }; } +function getProjectIdForTool(startupOptions: ApifoxApiOptions, args: CommandArgs): string { + const projectId = getString(args, "projectId") ?? startupOptions.projectId; + if (!projectId) { + throw new Error("缺少必要参数: projectId"); + } + return projectId; +} + function resolveRequestApi(startupOptions: ApifoxApiOptions, args: CommandArgs): ApifoxAPI { if (hasExplicitSource(startupOptions)) { return new ApifoxAPI(startupOptions); @@ -333,15 +546,35 @@ export function createApifoxMcpServer( switch (request.params.name) { case toolNames.readOas: - return toJsonText(JSON.parse(await apifoxApi.readProjectOas()) as unknown); + return toResultText(JSON.parse(await apifoxApi.readProjectOas()) as unknown); case toolNames.readRefs: - return toJsonText( + return toResultText( await apifoxApi.readProjectOasRefResources(getRequiredStringArray(args, "path")), ); case toolNames.refreshOas: - return toJsonText(await apifoxApi.refreshProjectOas()); + return toResultText(await apifoxApi.refreshProjectOas()); + case toolNames.readProject: { + const projectId = getProjectIdForTool(startupOptions, args); + return toResultText( + await apifoxApi.fetchProjectDocument(projectId, { + scope: buildProjectScope(args), + options: { + includeApifoxExtensionProperties: getBoolean( + args, + "includeApifoxExtensionProperties", + ), + addFoldersToTags: getBoolean(args, "addFoldersToTags"), + }, + oasVersion: getString(args, "oasVersion"), + exportFormat: getString(args, "exportFormat"), + environmentIds: getNumberArray(args, "environmentIds"), + branchId: getOptionalNumber(args, "branchId"), + moduleId: getOptionalNumber(args, "moduleId"), + }), + ); + } case toolNames.cacheInfo: - return toJsonText(await apifoxApi.getResolvedCacheInfo()); + return toResultText(await apifoxApi.getResolvedCacheInfo()); default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/skills/apifox-cli/SKILL.md b/skills/apifox-cli/SKILL.md index 317aa1e..971ab7d 100644 --- a/skills/apifox-cli/SKILL.md +++ b/skills/apifox-cli/SKILL.md @@ -13,14 +13,27 @@ If this is your first time using the CLI, see [references/installation.md](references/installation.md). Installation is a one-time prerequisite rather than part of the normal workflow. +During setup, automatically check whether the installed CLI is older than the +latest published package so the user can stay on the newest version. Follow +[references/version-check.md](references/version-check.md) and prompt the user +to update when a newer version is available. + ## AI Workflow -1. **Confirm source selection**: Pass one of `--projectId`, `--siteId`, or - `--oas` explicitly. Project mode also requires `APIFOX_ACCESS_TOKEN`. -2. **Inspect cache state**: Start with `cache info` before deciding whether a - refresh is necessary. -3. **Run the command**: Use `apifox `. -4. **Verify the result**: Re-run `oas view` or `refs read` after refreshes or +1. **Choose command mode**: Use `project ` for a live Apifox project export + without cache. Use `oas`, `refs`, or `cache` when working with cached + OpenAPI data. +2. **Check version freshness when behavior matters**: Compare the installed CLI + version with the latest published package version before debugging unknown + commands, missing flags, or behavior that may have changed recently. If a + newer version exists, tell the user to update before continuing. +3. **Confirm source selection for cache commands**: For `oas`, `refs`, and + `cache`, pass one of `--projectId`, `--siteId`, or `--oas` explicitly. + Project sources require `APIFOX_ACCESS_TOKEN`. +4. **Inspect cache state when relevant**: Start cached workflows with + `cache info` before deciding whether a refresh is necessary. +5. **Run the command**: Use `apifox `. +6. **Verify the result**: Re-run `oas view` or `refs read` after refreshes or cache-related work. ## Command Usage @@ -48,11 +61,40 @@ Use built-in help when needed: ```bash apifox --help apifox --version +apifox project --help apifox oas --help apifox refs --help apifox cache --help ``` +## Live Project Export + +Use `project ` when you need live OpenAPI data from an Apifox project and +do not want to read or write the local OAS cache: + +```bash +apifox project 12345 \ + --selected-tags User \ + --oas-version 3.1 +``` + +Rules for `project `: + +- Put the Apifox project ID in the positional `` argument. +- Do not pass `--projectId`, `--siteId`, or `--oas`; those source flags are + only for cache-backed commands. +- Do not pass `--apiPageSize` or `--dataLocation`; `project ` does not + materialize the OAS cache. +- Provide exactly one scope selector: + - `--selected-tags` + - `--selected-folder-ids` + - `--selected-endpoint-ids` +- Optional project export flags include `--excluded-by-tags`, + `--include-apifox-extension-properties`, `--add-folders-to-tags`, + `--oas-version`, `--export-format`, `--branch-id`, `--module-id`, and + `--environment-ids`. +- `APIFOX_ACCESS_TOKEN` or `--token` is required for Apifox project access. + ## OpenAPI Documents ```bash @@ -60,6 +102,10 @@ apifox oas view --projectId 12345 apifox oas refresh --projectId 12345 ``` +`oas` is cache-backed. Use `--apiPageSize` only here when controlling how +`paths` are split into paginated cache files. Use `--dataLocation` here only +when overriding the cache root. + For local or remote OAS sources: ```bash @@ -78,6 +124,9 @@ apifox refs read \ --path /components/schemas/User.json ``` +`refs read` reads existing cache files. It accepts `--dataLocation` to locate a +non-default cache root, but it does not accept `--apiPageSize`. + ## Cache ```bash @@ -85,6 +134,9 @@ apifox cache info --projectId 12345 apifox cache info --oas /tmp/openapi.json ``` +`cache info` accepts `--dataLocation` to inspect a non-default cache root, but +it does not accept `--apiPageSize`. + `cache info` returns JSON including: - `cacheDir` @@ -109,6 +161,14 @@ apifox oas view --projectId 12345 apifox refs read --projectId 12345 --path /components/schemas/index.json ``` +A live project export workflow: + +```bash +apifox project 12345 \ + --selected-endpoint-ids 1001,1002 \ + --export-format JSON +``` + ## Safety - Do not put `APIFOX_ACCESS_TOKEN` into repository files, shell history, or @@ -120,8 +180,15 @@ apifox refs read --projectId 12345 --path /components/schemas/index.json ## Troubleshooting -- **Missing required arguments**: Confirm the command includes `--projectId`, - `--siteId`, or `--oas`. +- **Potentially outdated CLI**: Compare `apifox --version` with + the latest published package version. See + [references/version-check.md](references/version-check.md). +- **Missing required arguments for cache commands**: Confirm `oas`, `refs`, and + `cache` include `--projectId`, `--siteId`, or `--oas`. +- **Unknown argument on `project `**: Remove cache/source flags such as + `--projectId`, `--siteId`, `--oas`, `--apiPageSize`, and `--dataLocation`. +- **Missing project scope**: `project ` requires exactly one of + `--selected-tags`, `--selected-folder-ids`, or `--selected-endpoint-ids`. - **Project read failure**: Confirm `APIFOX_ACCESS_TOKEN` matches the target project. - **Cache path is not writable**: Set `--dataLocation` or diff --git a/skills/apifox-cli/references/version-check.md b/skills/apifox-cli/references/version-check.md new file mode 100644 index 0000000..783b48c --- /dev/null +++ b/skills/apifox-cli/references/version-check.md @@ -0,0 +1,32 @@ +# Automatic Version Check + +Use this reference during setup and whenever CLI behavior may depend on the +installed package version. The goal is to automatically check for a newer +published package version and prompt the user to keep the CLI up to date. + +## Check Installed Version + +```bash +apifox --version +``` + +## Check Latest Published Version + +```bash +npm view @acehubert/apifox-mcp version +``` + +## Update Prompt Rule + +If the latest published version is newer than the installed version, notify the +user and ask them to update so they stay on the newest CLI before relying on +version-sensitive commands or flags. + +Suggested update command: + +```bash +npm i @acehubert/apifox-mcp@latest -g +``` + +Do not automatically install or update the global package unless the user +explicitly approves the package-management operation. diff --git a/skills/apifox/SKILL.md b/skills/apifox/SKILL.md index 82cf80d..eb101fc 100644 --- a/skills/apifox/SKILL.md +++ b/skills/apifox/SKILL.md @@ -39,6 +39,10 @@ Example MCP configuration: splits `paths` and `components` into `$ref` files. For large specs, read the main index first and then fetch only the referenced files you need. +**Live project export**: The project tool reads OpenAPI data directly from an +Apifox project and does not use the local OAS cache. Use it when the task needs +a scoped, fresh export by tags, folders, or endpoints. + **JSON output**: Tool responses are JSON text. Inspect fields, source settings, and `$ref` paths before using the result in follow-up work. @@ -48,9 +52,12 @@ and `$ref` paths before using the result in follow-up work. 1. Confirm whether the source is a `project`, `site`, or `oas`. 2. If it is a `project`, confirm `APIFOX_ACCESS_TOKEN` is available. -3. Call `get_apifox_cache_info` to check cache state. -4. Call `refresh_apifox_oas` when you need the latest document. +3. For cache-backed reads, call `get_apifox_cache_info` to check cache state. +4. For cache-backed reads, call `refresh_apifox_oas` when you need the latest + document. 5. Then use `read_apifox_oas` or `read_apifox_oas_ref_resources`. +6. For live scoped project reads, use `read_apifox_project` instead of the cache + tools. ### Document reading @@ -60,6 +67,26 @@ and `$ref` paths before using the result in follow-up work. - For large specs, prefer reading only the relevant referenced files instead of expanding everything. +### Live project reading + +- Use `read_apifox_project` when reading directly from an Apifox project without + creating or using the OAS cache. +- Pass the Apifox project as `projectId` unless the MCP server was started with + a project ID and the generated tool defaults to it. +- Do not pass `siteId` or `oas`; live project reading only supports Apifox + project exports. +- Do not pass `apiPageSize` or `dataLocation`; those only apply to cache-backed + reads. +- Provide exactly one scope selector: + - `selectedTags` + - `selectedFolderIds` + - `selectedEndpointIds` +- Optional export parameters include `excludedByTags`, + `includeApifoxExtensionProperties`, `addFoldersToTags`, `oasVersion`, + `exportFormat`, `branchId`, `moduleId`, and `environmentIds`. +- Use `read_apifox_project` for fresh scoped output; use `refresh_apifox_oas` + plus `read_apifox_oas_ref_resources` when you need reusable cache files. + ### Cache refresh - Use `refresh_apifox_oas` when the user asks for the latest API documentation. @@ -78,6 +105,11 @@ and `$ref` paths before using the result in follow-up work. - **Referenced resources**: `read_apifox_oas_ref_resources` - **Refresh cache**: `refresh_apifox_oas` - **Cache details**: `get_apifox_cache_info` +- **Live project export**: `read_apifox_project` + +When the MCP server is started with an explicit source, tool names may include a +stable source suffix. Use the listed tool name from the active MCP client rather +than guessing it. ## Efficient Retrieval @@ -99,6 +131,8 @@ Keep dependent operations ordered: `get_apifox_cache_info -> refresh_apifox_oas -> read_apifox_oas -> read_apifox_oas_ref_resources` Do not run multiple refresh operations against the same source in parallel. +Independent `read_apifox_project` calls for different scopes can run in +parallel when they target disjoint information needs. ## Safety @@ -112,6 +146,11 @@ Do not run multiple refresh operations against the same source in parallel. - **Missing source config**: Confirm the startup command includes `--projectId`, `--siteId`, or `--oas`. +- **Missing project ID for live project read**: Pass `projectId` to + `read_apifox_project`, unless the server was started with a project ID. +- **Invalid live project arguments**: Remove `siteId`, `oas`, `apiPageSize`, and + `dataLocation`; provide exactly one of `selectedTags`, `selectedFolderIds`, or + `selectedEndpointIds`. - **Project read failure**: Confirm `APIFOX_ACCESS_TOKEN` is valid and the project is accessible to that account. - **Remote OAS failure**: Confirm the URL is reachable and returns valid JSON. diff --git a/skills/testlink-cli/SKILL.md b/skills/testlink-cli/SKILL.md index b45f680..fdb0729 100644 --- a/skills/testlink-cli/SKILL.md +++ b/skills/testlink-cli/SKILL.md @@ -9,21 +9,29 @@ MCP client. ## Setup -_Note: If this is your first time using the CLI, see -[references/installation.md](references/installation.md) to install the command -and configure the connection. Installation is a one-time prerequisite and is -not part of the regular AI workflow._ +If this is your first time using the CLI, see +[references/installation.md](references/installation.md). Installation is a +one-time prerequisite rather than part of the normal workflow. + +During setup, automatically check whether the installed CLI is older than the +latest published package so the user can stay on the newest version. Follow +[references/version-check.md](references/version-check.md) and prompt the user +to update when a newer version is available. ## AI Workflow 1. **Confirm configuration**: Prefer environment variables `TESTLINK_URL` and `TESTLINK_API_KEY` so API keys are not written into shell history. -2. **Inspect before writing**: Before create, update, delete, close, or +2. **Check version freshness when behavior matters**: Compare the installed CLI + version with the latest published package version before debugging unknown + commands, missing flags, or behavior that may have changed recently. If a + newer version exists, tell the user to update before continuing. +3. **Inspect before writing**: Before create, update, delete, close, or execution actions, use `view` or `list` to confirm the target object exists and is in the expected state. -3. **Execute**: Run `testlink ` directly. Output defaults to +4. **Execute**: Run `testlink ` directly. Output defaults to formatted JSON and can be piped into `jq`, scripts, or later analysis. -4. **Verify**: After a write action, run `view` or `list` again to confirm the +5. **Verify**: After a write action, run `view` or `list` again to confirm the state, title, content, assignment, or execution result changed as expected. ## Command Usage diff --git a/skills/testlink-cli/references/version-check.md b/skills/testlink-cli/references/version-check.md new file mode 100644 index 0000000..27c4c69 --- /dev/null +++ b/skills/testlink-cli/references/version-check.md @@ -0,0 +1,32 @@ +# Automatic Version Check + +Use this reference during setup and whenever CLI behavior may depend on the +installed package version. The goal is to automatically check for a newer +published package version and prompt the user to keep the CLI up to date. + +## Check Installed Version + +```bash +testlink --version +``` + +## Check Latest Published Version + +```bash +npm view @acehubert/testlink-mcp version +``` + +## Update Prompt Rule + +If the latest published version is newer than the installed version, notify the +user and ask them to update so they stay on the newest CLI before relying on +version-sensitive commands or flags. + +Suggested update command: + +```bash +npm i @acehubert/testlink-mcp@latest -g +``` + +Do not automatically install or update the global package unless the user +explicitly approves the package-management operation. diff --git a/skills/zentao-cli/SKILL.md b/skills/zentao-cli/SKILL.md index 7a78c8f..4997ad1 100644 --- a/skills/zentao-cli/SKILL.md +++ b/skills/zentao-cli/SKILL.md @@ -9,26 +9,34 @@ client. ## Setup -_Note: If this is your first time using the CLI, see -[references/installation.md](references/installation.md) to install the command -and configure the connection. Installation is a one-time prerequisite and is -not part of the regular AI workflow._ +If this is your first time using the CLI, see +[references/installation.md](references/installation.md). Installation is a +one-time prerequisite rather than part of the normal workflow. + +During setup, automatically check whether the installed CLI is older than the +latest published package so the user can stay on the newest version. Follow +[references/version-check.md](references/version-check.md) and prompt the user +to update when a newer version is available. ## AI Workflow 1. **Confirm configuration**: Prefer environment variables `ZENTAO_URL`, `ZENTAO_ACCOUNT`, `ZENTAO_PASSWORD`, `ZENTAO_VERSION`, and `ZENTAO_SKIP_SSL` so passwords are not written into shell history. -2. **Confirm client version**: Before using version-sensitive commands or +2. **Check version freshness when behavior matters**: Compare the installed CLI + version with the latest published package version before debugging unknown + commands, missing flags, or behavior that may have changed recently. If a + newer version exists, tell the user to update before continuing. +3. **Confirm client version**: Before using version-sensitive commands or arguments, run `zentao client getVersion`. Use the returned `clientVersion` value (`legacy`, `v1`, or `v2`) to choose supported argument values, such as `browseType`. -3. **Inspect before writing**: Before create, resolve, close, or edit actions, +4. **Inspect before writing**: Before create, resolve, close, or edit actions, use `view` or `list` to confirm the target object exists and is in the expected state. -4. **Execute**: Run `zentao ` directly. Output defaults to +5. **Execute**: Run `zentao ` directly. Output defaults to formatted JSON and can be piped into `jq`, scripts, or later analysis. -5. **Verify**: After a write action, run `view` again to confirm the state, +6. **Verify**: After a write action, run `view` again to confirm the state, title, content, or comment changed as expected. ## Command Usage diff --git a/skills/zentao-cli/references/version-check.md b/skills/zentao-cli/references/version-check.md new file mode 100644 index 0000000..8b24b67 --- /dev/null +++ b/skills/zentao-cli/references/version-check.md @@ -0,0 +1,32 @@ +# Automatic Version Check + +Use this reference during setup and whenever CLI behavior may depend on the +installed package version. The goal is to automatically check for a newer +published package version and prompt the user to keep the CLI up to date. + +## Check Installed Version + +```bash +zentao --version +``` + +## Check Latest Published Version + +```bash +npm view @acehubert/zentao-mcp version +``` + +## Update Prompt Rule + +If the latest published version is newer than the installed version, notify the +user and ask them to update so they stay on the newest CLI before relying on +version-sensitive commands or flags. + +Suggested update command: + +```bash +npm i @acehubert/zentao-mcp@latest -g +``` + +Do not automatically install or update the global package unless the user +explicitly approves the package-management operation.