From ab7a3f83fac1332d2f0b7c46722248ec2e0ed49d Mon Sep 17 00:00:00 2001 From: hubert Date: Sat, 9 May 2026 17:12:16 +0800 Subject: [PATCH] refactor(mcp): merge testlink and apifox mcp servers into monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 @acehubert/apifox-mcp 与 @acehubert/testlink-mcp 两个独立包 - 统一使用 lerna independent 版本策略,根目录更名为 root - 移除 prepublishOnly 中的 changelog 生成步骤,改为独立脚本 - 新增 skills 目录放置 apifox、testlink 及其 cli 使用文档 --- .claude/skills/apifox | 1 + .claude/skills/apifox-cli | 1 + .claude/skills/testlink | 1 + .claude/skills/testlink-cli | 1 + lerna.json | 2 +- package.json | 7 +- packages/apifox-mcp/package.json | 61 ++ packages/apifox-mcp/src/api.ts | 682 ++++++++++++++++++ packages/apifox-mcp/src/cli.ts | 263 +++++++ packages/apifox-mcp/src/index.ts | 372 ++++++++++ packages/apifox-mcp/tsconfig.json | 14 + packages/testlink-mcp/package.json | 62 ++ packages/testlink-mcp/src/api.ts | 454 ++++++++++++ packages/testlink-mcp/src/cli.ts | 456 ++++++++++++ packages/testlink-mcp/src/config.ts | 145 ++++ packages/testlink-mcp/src/index.ts | 529 ++++++++++++++ packages/testlink-mcp/tsconfig.json | 14 + skills/apifox-cli/SKILL.md | 130 ++++ skills/apifox-cli/references/installation.md | 55 ++ skills/apifox/SKILL.md | 121 ++++ skills/testlink-cli/SKILL.md | 181 +++++ .../testlink-cli/references/installation.md | 37 + skills/testlink/SKILL.md | 151 ++++ yarn.lock | 127 +++- 24 files changed, 3831 insertions(+), 36 deletions(-) create mode 120000 .claude/skills/apifox create mode 120000 .claude/skills/apifox-cli create mode 120000 .claude/skills/testlink create mode 120000 .claude/skills/testlink-cli create mode 100644 packages/apifox-mcp/package.json create mode 100644 packages/apifox-mcp/src/api.ts create mode 100644 packages/apifox-mcp/src/cli.ts create mode 100644 packages/apifox-mcp/src/index.ts create mode 100644 packages/apifox-mcp/tsconfig.json create mode 100644 packages/testlink-mcp/package.json create mode 100644 packages/testlink-mcp/src/api.ts create mode 100644 packages/testlink-mcp/src/cli.ts create mode 100644 packages/testlink-mcp/src/config.ts create mode 100644 packages/testlink-mcp/src/index.ts create mode 100644 packages/testlink-mcp/tsconfig.json create mode 100644 skills/apifox-cli/SKILL.md create mode 100644 skills/apifox-cli/references/installation.md create mode 100644 skills/apifox/SKILL.md create mode 100644 skills/testlink-cli/SKILL.md create mode 100644 skills/testlink-cli/references/installation.md create mode 100644 skills/testlink/SKILL.md diff --git a/.claude/skills/apifox b/.claude/skills/apifox new file mode 120000 index 0000000..c8613d8 --- /dev/null +++ b/.claude/skills/apifox @@ -0,0 +1 @@ +../../skills/apifox \ No newline at end of file diff --git a/.claude/skills/apifox-cli b/.claude/skills/apifox-cli new file mode 120000 index 0000000..6d1f2ef --- /dev/null +++ b/.claude/skills/apifox-cli @@ -0,0 +1 @@ +../../skills/apifox-cli \ No newline at end of file diff --git a/.claude/skills/testlink b/.claude/skills/testlink new file mode 120000 index 0000000..49f8bf9 --- /dev/null +++ b/.claude/skills/testlink @@ -0,0 +1 @@ +../../skills/testlink \ No newline at end of file diff --git a/.claude/skills/testlink-cli b/.claude/skills/testlink-cli new file mode 120000 index 0000000..901064f --- /dev/null +++ b/.claude/skills/testlink-cli @@ -0,0 +1 @@ +../../skills/testlink-cli \ No newline at end of file diff --git a/lerna.json b/lerna.json index 99c00c6..b754ccf 100644 --- a/lerna.json +++ b/lerna.json @@ -1,7 +1,7 @@ { "packages": ["packages/*"], "npmClient": "yarn", - "version": "0.5.0", + "version": "independent", "command": { "publish": { "conventionalCommits": true, diff --git a/package.json b/package.json index ccb2f06..3da10e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "zentao", - "version": "0.5.0", + "name": "root", + "version": "0.0.0", "private": true, "scripts": { "typecheck": "lerna run typecheck --stream --scope '@acehubert/*'", @@ -13,7 +13,7 @@ "release:next": "lerna publish --conventional-commits --conventional-prerelease --preid next --dist-tag next", "release:graduate": "lerna publish --conventional-commits --conventional-graduate", "changelog": "node ./scripts/genChangelog.js", - "prepublishOnly": "yarn install --mode=skip-build && yarn changelog && yarn build && yarn test", + "prepublishOnly": "yarn install --mode=skip-build && yarn build && yarn test", "lint": "lerna run lint --stream --scope '@acehubert/*'", "lint:fix": "lerna run lint:fix --stream --scope '@acehubert/*'", "commit": "git-cz" @@ -25,6 +25,7 @@ "@instructure/cz-lerna-changelog": "^8.56.2", "@types/jest": "^29.5.14", "@types/node": "^18.0.0", + "@types/yargs": "^17.0.35", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "commitizen": "^4.3.1", diff --git a/packages/apifox-mcp/package.json b/packages/apifox-mcp/package.json new file mode 100644 index 0000000..1ba0965 --- /dev/null +++ b/packages/apifox-mcp/package.json @@ -0,0 +1,61 @@ +{ + "name": "@acehubert/apifox-mcp", + "version": "0.1.0", + "author": "aceHubert", + "license": "MIT", + "description": "Apifox MCP Server and CLI - 让 AI 助手读取和刷新 Apifox/OpenAPI 文档缓存", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "apifox": "dist/cli.cjs", + "apifox-mcp": "dist/index.cjs" + }, + "files": [ + "dist", + "README.md", + ".env.example" + ], + "keywords": [ + "mcp", + "apifox", + "openapi", + "cli", + "model-context-protocol" + ], + "scripts": { + "dev": "node --loader ts-node/esm --experimental-specifier-resolution=node src/index.ts", + "build": "run -T rimraf -rf dist && yarn build:types && yarn build:esm && yarn build:cjs && yarn build:cli", + "build:types": "run -T tsc --declaration --emitDeclarationOnly --declarationMap false --sourceMap false", + "build:esm": "run -T esbuild src/index.ts --bundle --packages=external --platform=node --target=node18 --format=esm --outfile=dist/index.js", + "build:cjs": "run -T esbuild src/index.ts --bundle --packages=external --platform=node --target=node18 --format=cjs --outfile=dist/index.cjs", + "build:cli": "run -T esbuild src/cli.ts --bundle --packages=external --platform=node --target=node18 --format=cjs --outfile=dist/cli.cjs", + "typecheck": "run -T tsc --noEmit", + "lint": "run -T eslint src --ext .ts --max-warnings=0", + "lint:fix": "run -T eslint src --ext .ts --fix" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "dotenv": "^17.2.3", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/aceHubert/zentao.git" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/apifox-mcp/src/api.ts b/packages/apifox-mcp/src/api.ts new file mode 100644 index 0000000..7759b7d --- /dev/null +++ b/packages/apifox-mcp/src/api.ts @@ -0,0 +1,682 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, promises as fs, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type JsonObject = Record; + +export interface ApifoxApiOptions { + token?: string; + projectId?: string; + siteId?: string; + oas?: string; + apiBaseUrl?: string; + apiVersion?: string; + apiPageSize?: number; + dataLocation?: string; +} + +export type ApifoxConfigKey = keyof ApifoxApiOptions; +export type ApifoxConfigValue = string | number; +export type ApifoxConfig = Partial>; + +export interface ResolvedApifoxApiOptions { + token?: string; + source: ApifoxSource; + apiBaseUrl: string; + apiVersion: string; + apiPageSize: number; + dataLocation: string; +} + +export type ApifoxSource = + | { name: "project"; identifier: string } + | { name: "doc-site"; identifier: string } + | { name: "remote-file"; identifier: string } + | { name: "local-file"; identifier: string }; + +export interface CacheInfo { + cacheDir: string; + cacheFile: string; + exists: boolean; + source: ApifoxSource; + lastUpdatedAt?: string; +} + +type OpenApiDocument = JsonObject & { + paths?: Record; + components?: Record>; + info?: JsonObject; +}; + +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; +const DEFAULT_DATA_ROOT_NAME = ".apifox-mcp-server"; +export const APIFOX_CONFIG_ROOT_NAME = DEFAULT_DATA_ROOT_NAME; +export const APIFOX_CONFIG_FILE = path.join(os.homedir(), APIFOX_CONFIG_ROOT_NAME, "config.json"); +export const APIFOX_CONFIG_KEYS = [ + "token", + "projectId", + "siteId", + "oas", + "apiBaseUrl", + "apiVersion", + "apiPageSize", + "dataLocation", +] as const satisfies readonly ApifoxConfigKey[]; + +function isRecord(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function getRequiredString(value: unknown, fieldName: string): string { + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error(`${fieldName} 必须是非空字符串`); + } + return normalized; +} + +function normalizePageSize(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === "string" && /^\d+$/.test(value)) { + return Number.parseInt(value, 10); + } + return DEFAULT_API_PAGE_SIZE; +} + +function normalizeOptionalPageSize(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = Number.parseInt(value, 10); + return parsed > 0 ? parsed : undefined; + } + return undefined; +} + +export function isApifoxConfigKey(value: string): value is (typeof APIFOX_CONFIG_KEYS)[number] { + return APIFOX_CONFIG_KEYS.includes(value as (typeof APIFOX_CONFIG_KEYS)[number]); +} + +export function normalizeApifoxConfigValue( + key: ApifoxConfigKey, + value: unknown, +): ApifoxConfigValue { + if (key === "apiPageSize") { + const numberValue = normalizeOptionalPageSize(value); + if (numberValue === undefined) { + throw new Error("apiPageSize 必须是大于 0 的整数"); + } + return numberValue; + } + + const normalized = normalizeOptionalString(value); + if (!normalized) { + throw new Error(`${key} 必须是非空字符串`); + } + + return normalized; +} + +export function readApifoxConfig(): ApifoxConfig { + try { + const content = readFileSync(APIFOX_CONFIG_FILE, "utf8"); + const parsed = JSON.parse(content) as unknown; + if (!isRecord(parsed)) { + return {}; + } + + const config: ApifoxConfig = {}; + for (const key of APIFOX_CONFIG_KEYS) { + const value = parsed[key]; + if (value === undefined) { + continue; + } + config[key] = normalizeApifoxConfigValue(key, value); + } + + return config; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {}; + } + throw error; + } +} + +export function writeApifoxConfig(config: ApifoxConfig): void { + mkdirSync(path.dirname(APIFOX_CONFIG_FILE), { recursive: true }); + writeFileSync(APIFOX_CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +export function resolveApifoxInputOptions(options: ApifoxApiOptions): ApifoxApiOptions { + const config = readApifoxConfig(); + + return { + token: + normalizeOptionalString(options.token) ?? + (typeof config.token === "string" ? config.token : undefined) ?? + normalizeOptionalString(process.env.APIFOX_ACCESS_TOKEN), + projectId: + normalizeOptionalString(options.projectId) ?? + (typeof config.projectId === "string" ? config.projectId : undefined) ?? + normalizeOptionalString(process.env.APIFOX_PROJECT_ID), + siteId: + normalizeOptionalString(options.siteId) ?? + (typeof config.siteId === "string" ? config.siteId : undefined) ?? + normalizeOptionalString(process.env.APIFOX_SITE_ID), + oas: + normalizeOptionalString(options.oas) ?? + (typeof config.oas === "string" ? config.oas : undefined) ?? + normalizeOptionalString(process.env.APIFOX_OAS), + apiBaseUrl: + normalizeOptionalString(options.apiBaseUrl) ?? + (typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : undefined) ?? + normalizeOptionalString(process.env.APIFOX_API_BASE_URL), + apiVersion: + normalizeOptionalString(options.apiVersion) ?? + (typeof config.apiVersion === "string" ? config.apiVersion : undefined) ?? + normalizeOptionalString(process.env.APIFOX_API_VERSION), + apiPageSize: + normalizeOptionalPageSize(options.apiPageSize) ?? + (typeof config.apiPageSize === "number" ? config.apiPageSize : undefined) ?? + normalizeOptionalPageSize(process.env.APIFOX_API_PAGE_SIZE), + dataLocation: + normalizeOptionalString(options.dataLocation) ?? + (typeof config.dataLocation === "string" ? config.dataLocation : undefined) ?? + normalizeOptionalString(process.env.APIFOX_DATA_LOCATION), + }; +} + +export function resolveApifoxEffectiveOptionValues( + options: ApifoxApiOptions = {}, +): Partial> { + const resolvedInputs = resolveApifoxInputOptions(options); + + return { + token: resolvedInputs.token, + projectId: resolvedInputs.projectId, + siteId: resolvedInputs.siteId, + oas: resolvedInputs.oas, + apiBaseUrl: resolvedInputs.apiBaseUrl ?? DEFAULT_API_BASE_URL, + apiVersion: resolvedInputs.apiVersion ?? DEFAULT_API_VERSION, + apiPageSize: normalizePageSize(resolvedInputs.apiPageSize), + dataLocation: resolvedInputs.dataLocation ?? os.homedir(), + }; +} + +function sanitizePathKey(key: string): string { + return key.replace(/\//g, "_"); +} + +function getNestedValue(record: JsonObject, dotPath: string): unknown { + return dotPath.split(".").reduce((current, segment) => { + if (!isRecord(current)) { + return undefined; + } + return current[segment]; + }, record); +} + +function setNestedValue(record: JsonObject, dotPath: string, value: unknown): void { + const segments = dotPath.split("."); + let current: JsonObject = record; + for (const segment of segments.slice(0, -1)) { + const existing = current[segment]; + if (!isRecord(existing)) { + current[segment] = {}; + } + current = current[segment] as JsonObject; + } + current[segments[segments.length - 1]] = value; +} + +function collectComponentRefs(value: unknown, refs: string[]): void { + if (!value || typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + collectComponentRefs(item, refs); + } + return; + } + + for (const [key, nestedValue] of Object.entries(value)) { + if ( + key === "$ref" && + typeof nestedValue === "string" && + nestedValue.startsWith("#/components/") + ) { + refs.push(nestedValue.slice("#/components/".length)); + continue; + } + collectComponentRefs(nestedValue, refs); + } +} + +function minimizeComponents(document: OpenApiDocument): OpenApiDocument { + if (!document.components) { + return document; + } + + const { components, ...rest } = document; + const pickedComponents: JsonObject = {}; + const walk = (value: unknown): void => { + const refs: string[] = []; + collectComponentRefs(value, refs); + + for (const ref of refs) { + const normalizedRef = ref.replace(/\//g, "."); + + if (getNestedValue(pickedComponents, normalizedRef) !== undefined) { + continue; + } + const nextValue = getNestedValue(components, normalizedRef); + if (nextValue !== undefined) { + setNestedValue(pickedComponents, normalizedRef, nextValue); + walk(nextValue); + } + } + }; + + walk(rest); + + const securitySchemes = components.securitySchemes; + if (securitySchemes && getNestedValue(pickedComponents, "securitySchemes") === undefined) { + setNestedValue(pickedComponents, "securitySchemes", securitySchemes); + } + + return { + ...rest, + components: pickedComponents as OpenApiDocument["components"], + }; +} + +async function writeJsonFile(filePath: string, data: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +async function writePathRefs( + cacheDir: string, + document: OpenApiDocument, + pageSize: number, +): Promise> { + const pathsRecord = document.paths ?? {}; + const pathKeys = Object.keys(pathsRecord); + const pathRefs: Record = {}; + + for (const pathKey of pathKeys) { + const refPath = `/paths/${sanitizePathKey(pathKey)}.json`; + const targetFile = path.join(cacheDir, refPath); + const pathItem = pathsRecord[pathKey] ?? {}; + await writeJsonFile(targetFile, pathItem); + + const summaries = Object.keys(pathItem).reduce>((result, method) => { + const methodValue = pathItem[method]; + if (isRecord(methodValue) && typeof methodValue.summary === "string") { + result[method] = { summary: methodValue.summary }; + } + return result; + }, {}); + + pathRefs[pathKey] = { $ref: refPath, ...summaries }; + } + + const pagination = { + page: Math.ceil(pathKeys.length / pageSize), + pageSize, + otherPages: {} as Record, + }; + + if (pagination.page > 1) { + const firstPageEntries = pathKeys.slice(0, pageSize); + const firstPageRecord: Record = {}; + + for (const pathKey of firstPageEntries) { + firstPageRecord[pathKey] = pathRefs[pathKey]; + } + + for (let offset = pageSize, page = 2; offset < pathKeys.length; offset += pageSize, page += 1) { + const pageKeys = pathKeys.slice(offset, offset + pageSize); + const pageRecord: Record = {}; + for (const pathKey of pageKeys) { + pageRecord[pathKey] = pathRefs[pathKey]; + } + + const pageRef = `/pages/${page}.json`; + await writeJsonFile(path.join(cacheDir, pageRef), pageRecord); + pagination.otherPages[page] = { $ref: pageRef }; + } + + return { + ...firstPageRecord, + "x-pagination": pagination as unknown as JsonObject, + }; + } + + return pathRefs; +} + +async function writeComponentIndex( + cacheDir: string, + document: OpenApiDocument, + componentName: string, +): Promise { + const componentRecord = document.components?.[componentName]; + if (!componentRecord) { + return undefined; + } + + const indexRecord: Record = {}; + for (const key of Object.keys(componentRecord)) { + const refPath = `/components/${componentName}/${key}.json`; + await writeJsonFile(path.join(cacheDir, refPath), componentRecord[key]); + indexRecord[key] = { $ref: refPath }; + } + + const indexPath = `/components/${componentName}/index.json`; + await writeJsonFile(path.join(cacheDir, indexPath), indexRecord); + return indexPath; +} + +export function resolveApifoxApiOptions(options: ApifoxApiOptions): ResolvedApifoxApiOptions { + const resolvedInputs = resolveApifoxInputOptions(options); + const source = resolveSourceFromInputs(resolvedInputs); + return { + token: resolvedInputs.token, + source, + apiBaseUrl: resolvedInputs.apiBaseUrl ?? DEFAULT_API_BASE_URL, + apiVersion: resolvedInputs.apiVersion ?? DEFAULT_API_VERSION, + apiPageSize: normalizePageSize(resolvedInputs.apiPageSize), + dataLocation: resolvedInputs.dataLocation ?? os.homedir(), + }; +} + +export function resolveSource(options: ApifoxApiOptions): ApifoxSource { + return resolveSourceFromInputs(resolveApifoxInputOptions(options)); +} + +function resolveSourceFromInputs(options: ApifoxApiOptions): ApifoxSource { + const oas = options.oas; + if (typeof oas === "string" && oas.trim().length > 0) { + const normalized = oas.trim(); + return /^https?:\/\//.test(normalized) + ? { name: "remote-file", identifier: normalized } + : { name: "local-file", identifier: normalized }; + } + + const siteId = options.siteId; + if (typeof siteId === "string" && siteId.trim().length > 0) { + return { name: "doc-site", identifier: siteId.trim() }; + } + + const projectId = options.projectId; + if (typeof projectId === "string" && projectId.trim().length > 0) { + return { name: "project", identifier: projectId.trim() }; + } + + throw new Error("projectId、siteId 或 oas 至少有一个是必须的"); +} + +export class ApifoxAPI { + readonly options: ResolvedApifoxApiOptions; + readonly cacheDir: string; + readonly cacheFile: string; + + constructor(options: ApifoxApiOptions) { + this.options = resolveApifoxApiOptions(options); + this.cacheDir = path.join( + this.options.dataLocation, + DEFAULT_DATA_ROOT_NAME, + this.getSourceCacheKey(), + ); + this.cacheFile = path.join(this.cacheDir, "index.json"); + } + + getCacheInfo(): CacheInfo { + return { + cacheDir: this.cacheDir, + cacheFile: this.cacheFile, + exists: false, + source: this.options.source, + }; + } + + async getResolvedCacheInfo(): Promise { + try { + const stat = await fs.stat(this.cacheFile); + return { + ...this.getCacheInfo(), + exists: true, + lastUpdatedAt: stat.mtime.toISOString(), + }; + } catch { + return this.getCacheInfo(); + } + } + + async readProjectOas(): Promise { + try { + return await fs.readFile(this.cacheFile, "utf8"); + } catch { + const document = await this.refreshProjectOas(); + return JSON.stringify(document, null, 2); + } + } + + async refreshProjectOas(): Promise { + const originalDocument = await this.fetchSourceDocument(); + return this.materializeDocument(originalDocument); + } + + async readProjectOasRefResources(pathsToRead: string[]): Promise> { + const result: Record = {}; + + for (const refPath of pathsToRead) { + const normalizedRef = getRequiredString(refPath, "path"); + const targetFile = path.join(this.cacheDir, normalizedRef); + result[normalizedRef] = await fs.readFile(targetFile, "utf8"); + } + + return result; + } + + getSource(): ApifoxSource { + return this.options.source; + } + + async getDocumentTitle(): Promise { + try { + const document = JSON.parse(await this.readProjectOas()) as unknown; + if (!isRecord(document)) { + return undefined; + } + const info = document.info; + if (!isRecord(info)) { + return undefined; + } + const title = info.title; + return typeof title === "string" && title.trim().length > 0 ? title : undefined; + } catch { + return undefined; + } + } + + private getSourceCacheKey(): string { + const { source } = this.options; + switch (source.name) { + case "project": + return `project-${source.identifier}`; + case "doc-site": + return `site-${source.identifier}`; + case "remote-file": + case "local-file": + return `oas-${createHash("md5").update(source.identifier).digest("hex").slice(0, 8)}`; + default: + return "oas-unknown"; + } + } + + private async requestJson(url: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("User-Agent", "apifox-mcp-server/0.0.1"); + headers.set("X-Apifox-Version", this.options.apiVersion); + + if (this.options.token) { + headers.set("Authorization", `Bearer ${this.options.token}`); + } + + if (init?.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `请求失败: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ); + } + + return response.json(); + } + + private async fetchSourceDocument(): Promise { + const { source } = this.options; + + switch (source.name) { + case "project": + if (!this.options.token) { + throw new Error("读取 Apifox 项目时必须提供 APIFOX_ACCESS_TOKEN"); + } + return this.fetchProjectDocument(source.identifier); + case "doc-site": + return this.fetchDocSiteDocument(source.identifier); + case "remote-file": + return this.fetchRemoteOas(source.identifier); + case "local-file": + return this.fetchLocalOas(source.identifier); + default: + throw new Error("不支持的来源类型"); + } + } + + 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, { + method: "POST", + body: JSON.stringify({ scope: { type: "ALL" } }), + }); + return this.assertOpenApi(result); + } + + private async fetchDocSiteDocument(siteId: string): Promise { + const url = new URL( + `/api/v1/docs-sites/${siteId}/export-mcp-data`, + this.options.apiBaseUrl, + ).toString(); + const result = await this.requestJson(url, { + method: "POST", + body: JSON.stringify({}), + }); + return this.assertOpenApi(result); + } + + private async fetchRemoteOas(oasUrl: string): Promise { + const response = await fetch(oasUrl); + if (!response.ok) { + throw new Error(`读取远程 OAS 失败: ${response.status} ${response.statusText}`); + } + return this.assertOpenApi(await response.json()); + } + + private async fetchLocalOas(filePath: string): Promise { + const content = await fs.readFile(filePath, "utf8"); + return this.assertOpenApi(JSON.parse(content) as unknown); + } + + private assertOpenApi(value: unknown): OpenApiDocument { + if (!isRecord(value)) { + throw new Error("OpenAPI 文档不是合法对象"); + } + return value as OpenApiDocument; + } + + private async materializeDocument(document: OpenApiDocument): Promise { + await fs.mkdir(this.cacheDir, { recursive: true }); + await writeJsonFile(path.join(this.cacheDir, "original.json"), document); + + const minimized = minimizeComponents(document); + const pathsIndex = await writePathRefs(this.cacheDir, minimized, this.options.apiPageSize); + const schemasIndex = await writeComponentIndex(this.cacheDir, minimized, "schemas"); + const securitySchemesIndex = await writeComponentIndex( + this.cacheDir, + minimized, + "securitySchemes", + ); + const requestBodiesIndex = await writeComponentIndex(this.cacheDir, minimized, "requestBodies"); + const responsesIndex = await writeComponentIndex(this.cacheDir, minimized, "responses"); + + const info = { + ...(minimized.info ?? {}), + "x-download-time": new Date().toISOString(), + }; + + const output: OpenApiDocument = { + ...minimized, + info, + paths: pathsIndex, + components: { + ...(minimized.components ?? {}), + }, + }; + + if (schemasIndex) { + output.components = output.components ?? {}; + output.components.schemas = { $ref: schemasIndex } as unknown as Record; + } + if (securitySchemesIndex) { + output.components = output.components ?? {}; + output.components.securitySchemes = { + $ref: securitySchemesIndex, + } as unknown as Record; + } + if (requestBodiesIndex) { + output.components = output.components ?? {}; + output.components.requestBodies = { + $ref: requestBodiesIndex, + } as unknown as Record; + } + if (responsesIndex) { + output.components = output.components ?? {}; + output.components.responses = { $ref: responsesIndex } as unknown as Record< + string, + JsonObject + >; + } + + delete output.tags; + + await writeJsonFile(this.cacheFile, output); + return output; + } +} diff --git a/packages/apifox-mcp/src/cli.ts b/packages/apifox-mcp/src/cli.ts new file mode 100644 index 0000000..83ce827 --- /dev/null +++ b/packages/apifox-mcp/src/cli.ts @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import dotenv from "dotenv"; +import yargs, { type Argv, type ArgumentsCamelCase } from "yargs"; +import { hideBin } from "yargs/helpers"; +import { + APIFOX_CONFIG_KEYS, + ApifoxAPI, + isApifoxConfigKey, + normalizeApifoxConfigValue, + readApifoxConfig, + resolveApifoxEffectiveOptionValues, + writeApifoxConfig, + type ApifoxApiOptions, +} from "./api.js"; + +dotenv.config({ quiet: true }); + +type CommandArgs = ArgumentsCamelCase>; + +function getString(args: CommandArgs, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" ? value : undefined; +} + +function getNumber(args: CommandArgs, key: string): number | undefined { + const value = args[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function getRequiredString(args: CommandArgs, key: string): string { + const value = getString(args, key); + if (!value) { + throw new Error(`缺少必要参数: ${key}`); + } + return value; +} + +function addConnectionOptions(parser: Argv): Argv { + return parser + .option("token", { + type: "string", + describe: "Access Token;", + }) + .option("projectId", { + type: "string", + alias: "project-id", + describe: "项目 ID;与 --siteId、--oas 三选一显式传入", + }) + .option("siteId", { + type: "string", + alias: "site-id", + describe: "文档站点 ID;与 --projectId、--oas 三选一显式传入", + }) + .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", { + type: "number", + alias: "api-page-size", + describe: "paths 拆分页大小;", + }) + .option("dataLocation", { + type: "string", + alias: "data-location", + describe: "缓存根目录;", + }); +} + +function getApifoxCliOptions(args: CommandArgs): ApifoxApiOptions { + return { + token: getString(args, "token"), + projectId: getString(args, "projectId"), + siteId: getString(args, "siteId"), + oas: getString(args, "oas"), + apiBaseUrl: getString(args, "apiBaseUrl"), + apiVersion: getString(args, "apiVersion"), + apiPageSize: getNumber(args, "apiPageSize"), + dataLocation: getString(args, "dataLocation"), + }; +} + +function getClient(args: CommandArgs): ApifoxAPI { + return new ApifoxAPI(getApifoxCliOptions(args)); +} + +function printJsonResult(result: unknown): void { + console.log(JSON.stringify(result, null, 2)); +} + +function registerOasCommands(parser: Argv): Argv { + return parser.command( + "oas ", + "OpenAPI 文档操作:view / refresh;", + (command) => + 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); + return; + case "refresh": + printJsonResult(await client.refreshProjectOas()); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerRefCommands(parser: Argv): Argv { + return parser.command( + "refs ", + "引用资源操作:read;", + (command) => + 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"); + + switch (action) { + case "read": { + const paths = args.path; + if (!Array.isArray(paths) || paths.some((item) => typeof item !== "string")) { + throw new Error("缺少必要参数: path"); + } + printJsonResult(await client.readProjectOasRefResources(paths as string[])); + return; + } + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerCacheCommands(parser: Argv): Argv { + return parser.command( + "cache ", + "缓存操作:info;返回缓存路径、来源与最后更新时间", + (command) => + 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()); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerConfigCommands(parser: Argv): Argv { + return parser.command( + "config [value]", + "本地 CLI 配置操作:get / set / remove;配置文件位于 ~/.apifox-mcp-server/config.json", + (command) => + command + .positional("action", { + choices: ["get", "set", "remove"] as const, + describe: "操作类型", + }) + .positional("key", { + choices: APIFOX_CONFIG_KEYS, + describe: "配置项名称", + }) + .positional("value", { + type: "string", + describe: "配置项值;仅 set 时必填", + }), + async (args: CommandArgs) => { + const action = getRequiredString(args, "action"); + const key = getRequiredString(args, "key"); + if (!isApifoxConfigKey(key)) { + throw new Error(`不支持的配置项: ${key}`); + } + + switch (action) { + case "get": { + const value = resolveApifoxEffectiveOptionValues()[key] ?? null; + console.log(value === null ? "null" : String(value)); + return; + } + case "set": { + const value = normalizeApifoxConfigValue(key, getRequiredString(args, "value")); + const config = readApifoxConfig(); + config[key] = value; + writeApifoxConfig(config); + + console.log(`${key}: ${String(value)}`); + return; + } + case "remove": { + const config = readApifoxConfig(); + delete config[key]; + writeApifoxConfig(config); + + console.log(`${key}: removed`); + return; + } + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +async function runCli(): Promise { + let parser = addConnectionOptions(yargs(hideBin(process.argv)).scriptName("apifox")); + parser = registerOasCommands(parser); + parser = registerRefCommands(parser); + parser = registerCacheCommands(parser); + parser = registerConfigCommands(parser); + + await parser + .demandCommand(1, "请指定一个命令") + .strict() + .help() + .alias("h", "help") + .version() + .alias("v", "version") + .parseAsync(); +} + +runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/packages/apifox-mcp/src/index.ts b/packages/apifox-mcp/src/index.ts new file mode 100644 index 0000000..8b4ad07 --- /dev/null +++ b/packages/apifox-mcp/src/index.ts @@ -0,0 +1,372 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import dotenv from "dotenv"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { + ApifoxAPI, + resolveApifoxInputOptions, + type ApifoxApiOptions, + type ApifoxSource, +} from "./api.js"; + +export { ApifoxAPI } from "./api.js"; +export type { ApifoxApiOptions } from "./api.js"; + +dotenv.config({ quiet: true }); + +type CommandArgs = Record; + +interface ToolNames { + readOas: string; + readRefs: string; + refreshOas: string; + cacheInfo: string; +} + +interface ToolMetadata { + names: ToolNames; + tools: Tool[]; +} + +function hasExplicitSource(options: ApifoxApiOptions): boolean { + return [options.projectId, options.siteId, options.oas].some( + (value) => typeof value === "string" && value.trim().length > 0, + ); +} + +function getString(args: CommandArgs, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" ? value : undefined; +} + +function getOptionalNumber(args: CommandArgs, key: string): number | undefined { + const value = args[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function addConnectionOptions(parser: ReturnType): ReturnType { + return parser + .option("token", { + type: "string", + describe: "Access Token;", + }) + .option("projectId", { + type: "string", + alias: "project-id", + describe: "项目 ID;", + }) + .option("siteId", { + type: "string", + alias: "site-id", + describe: "文档站点 ID;", + }) + .option("oas", { + type: "string", + describe: "远程或本地 OpenAPI 文件地址;", + }) + .option("apiBaseUrl", { + type: "string", + alias: "apifox-api-base-url", + describe: "API 基础地址;", + }) + .option("apiVersion", { + type: "string", + alias: "api-version", + describe: "API 版本头;", + }) + .option("apiPageSize", { + type: "number", + alias: "api-page-size", + describe: "paths 拆分页大小;", + }) + .option("dataLocation", { + type: "string", + alias: "data-location", + describe: "缓存根目录;", + }); +} + +export function getApifoxMcpOptions(args: CommandArgs): ApifoxApiOptions { + return { + token: getString(args, "token"), + projectId: getString(args, "projectId"), + siteId: getString(args, "siteId"), + oas: getString(args, "oas"), + apiBaseUrl: getString(args, "apiBaseUrl"), + apiVersion: getString(args, "apiVersion"), + apiPageSize: getOptionalNumber(args, "apiPageSize"), + dataLocation: getString(args, "dataLocation"), + }; +} + +function parseMcpOptions(): ApifoxApiOptions { + const args = addConnectionOptions(yargs(hideBin(process.argv)).scriptName("apifox-mcp")) + .help(false) + .version(false) + .exitProcess(false) + .showHelpOnFail(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseSync() as CommandArgs; + + return getApifoxMcpOptions(args); +} + +function getRequiredStringArray(args: CommandArgs, key: string): string[] { + const value = args[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`缺少必要参数: ${key}`); + } + return value as string[]; +} + +function toJsonText(result: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; +} + +function createSourceSchemaProperties() { + return { + projectId: { + type: "string", + description: "Apifox 项目 ID;当服务启动时未传入来源参数时,与 siteId、oas 三选一", + }, + siteId: { + type: "string", + description: "Apifox 文档站点 ID;当服务启动时未传入来源参数时,与 projectId、oas 三选一", + }, + oas: { + type: "string", + description: + "远程或本地 OpenAPI 文件地址;当服务启动时未传入来源参数时,与 projectId、siteId 三选一", + }, + }; +} + +function getSourceLabel(source: ApifoxSource): string { + switch (source.name) { + case "project": + return `Apifox 项目 ${source.identifier}`; + case "doc-site": + return `Apifox 文档站点 ${source.identifier}`; + case "remote-file": + return `远程 OAS ${source.identifier}`; + case "local-file": + return `本地 OAS ${source.identifier}`; + default: + return "当前来源"; + } +} + +function sanitizeToolSuffix(value: string): string { + const normalized = value + .trim() + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized || "default"; +} + +function getToolSuffixFromStartupOptions(startupOptions: ApifoxApiOptions): string { + if (startupOptions.projectId) { + return sanitizeToolSuffix(startupOptions.projectId); + } + + if (startupOptions.siteId) { + return `site_${sanitizeToolSuffix(startupOptions.siteId)}`; + } + + if (startupOptions.oas) { + return `oas_${createHash("md5").update(startupOptions.oas).digest("hex").slice(0, 8)}`; + } + + return "default"; +} + +function buildToolNames(startupOptions: ApifoxApiOptions): ToolNames { + if (!hasExplicitSource(startupOptions)) { + return { + readOas: "read_apifox_oas", + readRefs: "read_apifox_oas_ref_resources", + refreshOas: "refresh_apifox_oas", + cacheInfo: "get_apifox_cache_info", + }; + } + + const suffix = getToolSuffixFromStartupOptions(startupOptions); + return { + readOas: `read_apifox_oas_${suffix}`, + readRefs: `read_apifox_oas_ref_resources_${suffix}`, + refreshOas: `refresh_apifox_oas_${suffix}`, + cacheInfo: `get_apifox_cache_info_${suffix}`, + }; +} + +function getDescriptionTarget(title: string | undefined, sourceLabel: string): string { + if (title) { + return `「${title}」`; + } + return `来源「${sourceLabel}」`; +} + +async function buildToolMetadata( + startupOptions: ApifoxApiOptions, + apifoxApi?: ApifoxAPI, +): Promise { + const needsProjectId = !hasExplicitSource(startupOptions); + const names = buildToolNames(startupOptions); + const sourceLabel = apifoxApi + ? getSourceLabel(apifoxApi.getSource()) + : "运行时指定的 Apifox 来源"; + const title = apifoxApi ? await apifoxApi.getDocumentTitle() : undefined; + const target = needsProjectId + ? "运行时指定的 Apifox 来源" + : getDescriptionTarget(title, sourceLabel); + const baseProperties = needsProjectId ? createSourceSchemaProperties() : {}; + const baseRequired = needsProjectId ? [] : []; + const baseOneOf = needsProjectId + ? [{ required: ["projectId"] }, { required: ["siteId"] }, { required: ["oas"] }] + : undefined; + + return { + names, + tools: [ + { + name: names.readOas, + description: `读取${target}的 OpenAPI 文档缓存;若本地无缓存会自动拉取并生成`, + inputSchema: { + type: "object", + properties: baseProperties, + required: baseRequired, + ...(baseOneOf ? { oneOf: baseOneOf } : {}), + }, + }, + { + name: names.readRefs, + description: `按 $ref 路径读取${target}拆分后的 OpenAPI 资源文件`, + inputSchema: { + type: "object", + properties: { + ...baseProperties, + path: { + type: "array", + items: { type: "string" }, + description: + '多个引用路径,例如 ["/paths/_users.json", "/components/schemas/User.json"]', + }, + }, + required: [...baseRequired, "path"], + ...(baseOneOf ? { oneOf: baseOneOf } : {}), + }, + }, + { + name: names.refreshOas, + description: `强制重新拉取${target}的 OpenAPI 文档并刷新本地缓存`, + inputSchema: { + type: "object", + properties: baseProperties, + required: baseRequired, + ...(baseOneOf ? { oneOf: baseOneOf } : {}), + }, + }, + { + name: names.cacheInfo, + description: `读取${target}的缓存目录与缓存文件信息`, + inputSchema: { + type: "object", + properties: baseProperties, + required: baseRequired, + ...(baseOneOf ? { oneOf: baseOneOf } : {}), + }, + }, + ], + }; +} + +function resolveRequestApi(startupOptions: ApifoxApiOptions, args: CommandArgs): ApifoxAPI { + if (hasExplicitSource(startupOptions)) { + return new ApifoxAPI(startupOptions); + } + + return new ApifoxAPI({ + ...startupOptions, + projectId: getString(args, "projectId"), + siteId: getString(args, "siteId"), + oas: getString(args, "oas"), + }); +} + +export function createApifoxMcpServer( + startupOptions: ApifoxApiOptions, + toolMetadata: ToolMetadata, +): Server { + const server = new Server( + { + name: "@acehubert/apifox-mcp", + version: "0.0.1", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMetadata.tools })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const args = (request.params.arguments ?? {}) as CommandArgs; + const toolNames = toolMetadata.names; + + try { + const apifoxApi = resolveRequestApi(startupOptions, args); + + switch (request.params.name) { + case toolNames.readOas: + return toJsonText(JSON.parse(await apifoxApi.readProjectOas()) as unknown); + case toolNames.readRefs: + return toJsonText( + await apifoxApi.readProjectOasRefResources(getRequiredStringArray(args, "path")), + ); + case toolNames.refreshOas: + return toJsonText(await apifoxApi.refreshProjectOas()); + case toolNames.cacheInfo: + return toJsonText(await apifoxApi.getResolvedCacheInfo()); + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + isError: true, + }; + } + }); + + return server; +} + +async function main(): Promise { + const startupOptions = resolveApifoxInputOptions(parseMcpOptions()); + const apifoxApi = hasExplicitSource(startupOptions) ? new ApifoxAPI(startupOptions) : undefined; + const toolMetadata = await buildToolMetadata(startupOptions, apifoxApi); + const server = createApifoxMcpServer(startupOptions, toolMetadata); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : "MCP Server 启动失败"); + process.exit(1); +}); diff --git a/packages/apifox-mcp/tsconfig.json b/packages/apifox-mcp/tsconfig.json new file mode 100644 index 0000000..1245f0f --- /dev/null +++ b/packages/apifox-mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "incremental": false, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "declaration": true, + "sourceMap": false, + "inlineSources": false, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/packages/testlink-mcp/package.json b/packages/testlink-mcp/package.json new file mode 100644 index 0000000..319ca38 --- /dev/null +++ b/packages/testlink-mcp/package.json @@ -0,0 +1,62 @@ +{ + "name": "@acehubert/testlink-mcp", + "version": "0.1.0", + "author": "aceHubert", + "license": "MIT", + "description": "TestLink MCP Server - 让 AI 助手能够管理 TestLink 测试用例、测试套件、测试计划和执行结果", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "testlink": "dist/cli.cjs", + "testlink-mcp": "dist/index.cjs" + }, + "files": [ + "dist", + "README.md", + ".env.example" + ], + "keywords": [ + "mcp", + "testlink", + "test-management", + "ai-assistant", + "model-context-protocol" + ], + "scripts": { + "dev": "node --loader ts-node/esm --experimental-specifier-resolution=node src/index.ts", + "build": "run -T rimraf -rf dist && yarn build:types && yarn build:esm && yarn build:cjs && yarn build:cli", + "build:types": "run -T tsc --declaration --emitDeclarationOnly --declarationMap false --sourceMap false", + "build:esm": "run -T esbuild src/index.ts --bundle --packages=external --platform=node --target=node18 --format=esm --outfile=dist/index.js", + "build:cjs": "run -T esbuild src/index.ts --bundle --packages=external --platform=node --target=node18 --format=cjs --outfile=dist/index.cjs", + "build:cli": "run -T esbuild src/cli.ts --bundle --packages=external --platform=node --target=node18 --format=cjs --outfile=dist/cli.cjs", + "typecheck": "run -T tsc --noEmit", + "lint": "run -T eslint src --ext .ts --max-warnings=0", + "lint:fix": "run -T eslint src --ext .ts --fix" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "dotenv": "^17.2.3", + "testlink-xmlrpc": "^3.0.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/aceHubert/zentao.git" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/testlink-mcp/src/api.ts b/packages/testlink-mcp/src/api.ts new file mode 100644 index 0000000..7b0b949 --- /dev/null +++ b/packages/testlink-mcp/src/api.ts @@ -0,0 +1,454 @@ +import { Constants, TestLink } from "testlink-xmlrpc"; + +export type TestLinkRecord = Record; + +export interface TestLinkApiOptions { + url: string; + apiKey: string; +} + +type TestLinkErrorResponse = { + code?: number; + message?: string; +}; + +function isRecord(value: unknown): value is TestLinkRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertRecord(value: unknown, fieldName: string): asserts value is TestLinkRecord { + if (!isRecord(value)) { + throw new Error(`${fieldName} must be an object`); + } +} + +function getRequiredString(data: TestLinkRecord, key: string, fieldName = key): string { + const value = data[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${fieldName} must be a non-empty string`); + } + return value; +} + +function getOptionalString(data: TestLinkRecord, key: string): string | undefined { + const value = data[key]; + return typeof value === "string" ? value : undefined; +} + +function getOptionalNumber(data: TestLinkRecord, key: string): number | undefined { + const value = data[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function getOptionalBoolean(data: TestLinkRecord, key: string): boolean | undefined { + const value = data[key]; + return typeof value === "boolean" ? value : undefined; +} + +function getOptionalArray(data: TestLinkRecord, key: string): unknown[] | undefined { + const value = data[key]; + return Array.isArray(value) ? value : undefined; +} + +function normalizeIntegerId(value: unknown, fieldName: string): number { + if (typeof value === "number" && Number.isInteger(value) && value >= 0) { + return value; + } + + if (typeof value === "string" && /^\d+$/.test(value)) { + return Number.parseInt(value, 10); + } + + throw new Error(`${fieldName} must contain only digits`); +} + +function validateNonEmptyString(value: string, fieldName: string): void { + if (value.trim().length === 0) { + throw new Error(`${fieldName} must be a non-empty string`); + } +} + +function isExternalTestCaseId(id: string): boolean { + return /^[A-Za-z0-9]+-\d+$/.test(id); +} + +export function parseTestCaseId(id: string): string { + if (!id || typeof id !== "string") { + throw new Error("Test case ID must be a non-empty string"); + } + + const externalIdMatch = id.match(/^[A-Za-z0-9]+-(\d+)$/); + if (externalIdMatch) { + return externalIdMatch[1]; + } + + if (/^\d+$/.test(id)) { + return id; + } + + throw new Error("Test case ID must be either numeric (123) or external format (PREFIX-123)"); +} + +function validateTestCaseId(id: string): void { + parseTestCaseId(id); +} + +function normalizeRpcPath(pathname: string): string { + const basePath = pathname.replace(/\/+$/, ""); + return `${basePath}/lib/api/xmlrpc/v1/xmlrpc.php`; +} + +export class TestLinkAPI { + private readonly client: any; + + constructor(url: string, apiKey: string) { + validateNonEmptyString(url, "TestLink URL"); + validateNonEmptyString(apiKey, "TestLink API key"); + + const parsedUrl = new URL(url); + this.client = new TestLink({ + host: parsedUrl.hostname, + port: parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : undefined, + secure: parsedUrl.protocol === "https:", + rpcPath: normalizeRpcPath(parsedUrl.pathname), + apiKey, + }); + } + + private async handleAPICall(apiCall: () => Promise): Promise { + try { + const result = await apiCall(); + if (Array.isArray(result) && isRecord(result[0]) && typeof result[0].code === "number") { + const errorResponse = result[0] as TestLinkErrorResponse; + const errorCode = errorResponse.code; + const errorMessage = errorResponse.message || "Unknown error"; + + if (errorCode === 2000) { + throw new Error("TestLink Authentication Failed: Invalid API key"); + } else if (errorCode === 3000) { + throw new Error(`TestLink Permission Denied: ${errorMessage}`); + } else if (errorCode === 7000) { + throw new Error(`TestLink Object Not Found: ${errorMessage}`); + } else { + throw new Error(`TestLink API Error (${errorCode}): ${errorMessage}`); + } + } + + return result; + } catch (error) { + if (isRecord(error) && error.code === "ECONNREFUSED") { + throw new Error("Cannot connect to TestLink. Please check TESTLINK_URL."); + } + if (isRecord(error) && error.code === "ETIMEDOUT") { + throw new Error("TestLink API request timed out"); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`API call failed: ${message}`); + } + } + + async getTestCase(testCaseId: string): Promise { + validateTestCaseId(testCaseId); + + if (isExternalTestCaseId(testCaseId)) { + return this.handleAPICall(() => this.client.getTestCase({ testcaseexternalid: testCaseId })); + } + + return this.handleAPICall(() => + this.client.getTestCase({ + testcaseid: parseTestCaseId(testCaseId), + }), + ); + } + + async updateTestCase(testCaseId: string, data: unknown): Promise { + validateTestCaseId(testCaseId); + assertRecord(data, "Update data"); + + const updateParams: TestLinkRecord = isExternalTestCaseId(testCaseId) + ? { testcaseexternalid: testCaseId } + : { testcaseid: parseTestCaseId(testCaseId) }; + + const name = getOptionalString(data, "name"); + const summary = getOptionalString(data, "summary"); + const preconditions = getOptionalString(data, "preconditions"); + const steps = getOptionalArray(data, "steps"); + const importance = getOptionalNumber(data, "importance"); + const executionType = getOptionalNumber(data, "execution_type"); + const status = getOptionalNumber(data, "status"); + + if (name) updateParams.testcasename = name; + if (summary) updateParams.summary = summary; + if (preconditions) updateParams.preconditions = preconditions; + if (steps) updateParams.steps = steps; + if (importance !== undefined) updateParams.importance = importance; + if (executionType !== undefined) updateParams.executiontype = executionType; + if (status !== undefined) updateParams.status = status; + + return this.handleAPICall(() => this.client.updateTestCase(updateParams)); + } + + async createTestCase(data: unknown): Promise { + assertRecord(data, "Test case data"); + + const testProjectId = normalizeIntegerId(data.testprojectid, "testprojectid"); + const testSuiteId = normalizeIntegerId(data.testsuiteid, "testsuiteid"); + const name = getRequiredString(data, "name", "Test case name"); + const authorLogin = getRequiredString(data, "authorlogin", "Author login"); + + const createParams = { + testprojectid: testProjectId, + testsuiteid: testSuiteId, + testcasename: name, + authorlogin: authorLogin, + summary: getOptionalString(data, "summary") || "", + steps: getOptionalArray(data, "steps") || [], + importance: getOptionalNumber(data, "importance") || 2, + executiontype: getOptionalNumber(data, "execution_type") || 1, + status: getOptionalNumber(data, "status") || 1, + }; + + return this.handleAPICall(() => this.client.createTestCase(createParams)); + } + + async deleteTestCase(testCaseId: string): Promise { + validateTestCaseId(testCaseId); + return this.updateTestCase(testCaseId, { status: 7 }); + } + + async getTestProjects(): Promise { + return this.handleAPICall(() => this.client.getProjects()); + } + + async getTestSuites(projectId: string): Promise { + const testProjectId = normalizeIntegerId(projectId, "Project ID"); + return this.handleAPICall(() => + this.client.getFirstLevelTestSuitesForTestProject({ + testprojectid: testProjectId, + }), + ); + } + + async getTestSuiteByID(suiteId: string): Promise { + const testSuiteId = normalizeIntegerId(suiteId, "Suite ID"); + return this.handleAPICall(() => + this.client.getTestSuiteByID({ + testsuiteid: testSuiteId, + }), + ); + } + + async getTestCasesForTestSuite(suiteId: string): Promise { + const testSuiteId = normalizeIntegerId(suiteId, "Suite ID"); + return this.handleAPICall(() => + this.client.getTestCasesForTestSuite({ + testsuiteid: testSuiteId, + deep: true, + details: Constants.Details.FULL, + }), + ); + } + + async createTestSuite( + projectId: string, + suiteName: string, + details = "", + parentId?: string, + ): Promise { + const testProjectId = normalizeIntegerId(projectId, "Project ID"); + validateNonEmptyString(suiteName, "Suite name"); + + const params: TestLinkRecord = { + testprojectid: testProjectId, + testsuitename: suiteName, + details, + }; + + if (parentId) { + params.parentid = normalizeIntegerId(parentId, "Parent suite ID"); + } + + return this.handleAPICall(() => this.client.createTestSuite(params)); + } + + async updateTestSuite(suiteId: string, projectId: string, data: unknown): Promise { + assertRecord(data, "Update data"); + + const updateParams: TestLinkRecord = { + testsuiteid: normalizeIntegerId(suiteId, "Suite ID"), + testprojectid: normalizeIntegerId(projectId, "Project ID"), + }; + + const name = getOptionalString(data, "name"); + const details = getOptionalString(data, "details"); + + if (name) updateParams.testsuitename = name; + if (details) updateParams.details = details; + + return this.handleAPICall(() => this.client.updateTestSuite(updateParams)); + } + + async getTestPlans(projectId: string): Promise { + const testProjectId = normalizeIntegerId(projectId, "Project ID"); + return this.handleAPICall(() => + this.client.getProjectTestPlans({ + testprojectid: testProjectId, + }), + ); + } + + async createTestPlan(data: unknown): Promise { + assertRecord(data, "Test plan data"); + + const projectId = getRequiredString(data, "project_id", "Project ID/prefix"); + const name = getRequiredString(data, "name", "Test plan name"); + + const createParams = { + testprojectname: projectId, + testplanname: name, + notes: getOptionalString(data, "notes") || "", + active: getOptionalNumber(data, "active") ?? 1, + is_public: getOptionalNumber(data, "is_public") ?? 1, + }; + + return this.handleAPICall(() => this.client.createTestPlan(createParams)); + } + + async deleteTestPlan(planId: string): Promise { + const testPlanId = normalizeIntegerId(planId, "Plan ID"); + return this.handleAPICall(() => + this.client.deleteTestPlan({ + testplanid: testPlanId, + }), + ); + } + + async getTestCasesForTestPlan(planId: string): Promise { + const testPlanId = normalizeIntegerId(planId, "Plan ID"); + return this.handleAPICall(() => + this.client.getTestCasesForTestPlan({ + testplanid: testPlanId, + }), + ); + } + + async addTestCaseToTestPlan(data: unknown): Promise { + assertRecord(data, "Assignment data"); + + const testCaseId = getRequiredString(data, "testcaseid", "Test case ID"); + validateTestCaseId(testCaseId); + + const params: TestLinkRecord = { + testprojectid: normalizeIntegerId(data.testprojectid, "testprojectid"), + testplanid: normalizeIntegerId(data.testplanid, "testplanid"), + version: getOptionalNumber(data, "version") || 1, + urgency: getOptionalNumber(data, "urgency") || 2, + overwrite: getOptionalBoolean(data, "overwrite") || false, + }; + + if (isExternalTestCaseId(testCaseId)) { + params.testcaseexternalid = testCaseId; + } else { + params.testcaseid = Number.parseInt(parseTestCaseId(testCaseId), 10); + } + + const platformId = data.platformid; + if (platformId !== undefined) { + params.platformid = normalizeIntegerId(platformId, "platformid"); + } + + return this.handleAPICall(() => this.client.addTestCaseToTestPlan(params)); + } + + async getBuilds(planId: string): Promise { + const testPlanId = normalizeIntegerId(planId, "Plan ID"); + return this.handleAPICall(() => + this.client.getBuildsForTestPlan({ + testplanid: testPlanId, + }), + ); + } + + async createBuild(data: unknown): Promise { + assertRecord(data, "Build data"); + + const createParams = { + testplanid: normalizeIntegerId(data.plan_id, "plan_id"), + buildname: getRequiredString(data, "name", "Build name"), + buildnotes: getOptionalString(data, "notes") || "", + active: getOptionalNumber(data, "active") ?? 1, + open: getOptionalNumber(data, "open") ?? 1, + releasedate: + getOptionalString(data, "release_date") || new Date().toISOString().split("T")[0], + }; + + return this.handleAPICall(() => this.client.createBuild(createParams)); + } + + async closeBuild(buildId: string): Promise { + const parsedBuildId = normalizeIntegerId(buildId, "Build ID"); + return this.handleAPICall(() => + this.client.closeBuild({ + buildid: parsedBuildId, + }), + ); + } + + async getTestExecutions(planId: string, buildId?: string): Promise { + const params: TestLinkRecord = { + testplanid: normalizeIntegerId(planId, "Plan ID"), + }; + + if (buildId) { + params.buildid = normalizeIntegerId(buildId, "Build ID"); + } + + return this.handleAPICall(() => this.client.getAllExecutionsResults(params)); + } + + async createTestExecution(data: unknown): Promise { + assertRecord(data, "Test execution data"); + + const testCaseId = getRequiredString(data, "test_case_id", "Test case ID"); + validateTestCaseId(testCaseId); + + const executionParams: TestLinkRecord = { + testplanid: normalizeIntegerId(data.plan_id, "plan_id"), + buildid: normalizeIntegerId(data.build_id, "build_id"), + status: getRequiredString(data, "status", "Execution status"), + notes: getOptionalString(data, "notes") || "", + steps: getOptionalArray(data, "steps") || [], + }; + + if (isExternalTestCaseId(testCaseId)) { + executionParams.testcaseexternalid = testCaseId; + } else { + executionParams.testcaseid = parseTestCaseId(testCaseId); + } + + const platformId = getOptionalString(data, "platform_id"); + if (platformId) { + executionParams.platformid = platformId; + } + + return this.handleAPICall(() => this.client.setTestCaseExecutionResult(executionParams)); + } + + async getRequirements(projectId: string): Promise { + const testProjectId = normalizeIntegerId(projectId, "Project ID"); + return this.handleAPICall(() => + this.client.getRequirements({ + testprojectid: testProjectId, + }), + ); + } + + async getRequirement(requirementId: string, projectId: string): Promise { + return this.handleAPICall(() => + this.client.getRequirement({ + requirementid: normalizeIntegerId(requirementId, "Requirement ID"), + testprojectid: normalizeIntegerId(projectId, "Project ID"), + }), + ); + } +} diff --git a/packages/testlink-mcp/src/cli.ts b/packages/testlink-mcp/src/cli.ts new file mode 100644 index 0000000..595e5e2 --- /dev/null +++ b/packages/testlink-mcp/src/cli.ts @@ -0,0 +1,456 @@ +#!/usr/bin/env node +/** + * TestLink 命令行工具 + * 将 MCP 能力按资源和 action 平铺为可直接执行的 CLI commands。 + */ + +import dotenv from "dotenv"; +import yargs, { type Argv, type ArgumentsCamelCase } from "yargs"; +import { hideBin } from "yargs/helpers"; +import { TestLinkAPI, type TestLinkRecord } from "./api.js"; +import { + configKeys, + inspectTestLinkConfig, + removeSavedTestLinkConfig, + resolveTestLinkConfig, + updateSavedTestLinkConfig, + type TestLinkConfigKey, + type TestLinkConnectionOptions, +} from "./config.js"; + +dotenv.config({ quiet: true }); + +type CommandArgs = ArgumentsCamelCase>; + +type TestLinkCliOptions = TestLinkConnectionOptions; + +function getString(args: CommandArgs, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" ? value : undefined; +} + +function addConnectionOptions(parser: Argv): Argv { + return parser + .option("url", { + type: "string", + describe: "服务地址;", + }) + .option("apiKey", { + type: "string", + alias: "api-key", + describe: "API Key;", + }); +} + +export function getTestLinkCliOptions(args: CommandArgs): TestLinkCliOptions { + return { + url: getString(args, "url"), + apiKey: getString(args, "apiKey"), + }; +} + +export function resolveTestLinkCliOptions( + options: TestLinkCliOptions, +): Required { + return resolveTestLinkConfig(options); +} + +function getRequiredString(args: CommandArgs, key: string): string { + const value = getString(args, key); + if (!value) { + throw new Error(`缺少必要参数: ${key}`); + } + return value; +} + +function getJsonObject(args: CommandArgs, key: string): TestLinkRecord | undefined { + const value = args[key]; + if (value === undefined) return undefined; + + if (typeof value !== "string") { + throw new Error(`参数 ${key} 必须是 JSON 对象字符串`); + } + + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`参数 ${key} 必须是 JSON 对象`); + } + + return parsed as TestLinkRecord; +} + +function printJsonResult(result: unknown): void { + console.log(JSON.stringify(result, null, 2)); +} + +function printConfigValue(key: TestLinkConfigKey, value: string | undefined): void { + console.log(`${key}: ${value ?? "null"}`); +} + +function printConfigValues(config: TestLinkCliOptions): void { + printConfigValue("url", config.url); + printConfigValue("apiKey", config.apiKey); +} + +function getClient(args: CommandArgs): TestLinkAPI { + const options = resolveTestLinkCliOptions(getTestLinkCliOptions(args)); + return new TestLinkAPI(options.url, options.apiKey); +} + +function normalizeConfigKey(key: string): TestLinkConfigKey { + if (key === "url") return "url"; + if (key === "apiKey" || key === "api-key") return "apiKey"; + throw new Error(`不支持的配置项: ${key},可用配置项: url, apiKey`); +} + +function registerConfigCommands(parser: Argv): Argv { + return parser.command( + "config [key] [value]", + "连接配置操作:get / set / remove", + (command) => + command + .positional("action", { + choices: ["get", "set", "remove"] as const, + describe: "操作类型", + }) + .positional("key", { + type: "string", + choices: configKeys, + describe: "配置项:url / apiKey", + }) + .positional("value", { + type: "string", + describe: "配置值", + }), + async (args: CommandArgs) => { + const action = getRequiredString(args, "action"); + + switch (action) { + case "get": { + const inspection = inspectTestLinkConfig(getTestLinkCliOptions(args)); + const rawKey = getString(args, "key"); + + if (rawKey) { + const key = normalizeConfigKey(rawKey); + printConfigValue(key, inspection.values[key]); + return; + } + + printConfigValues(inspection.values); + return; + } + case "set": { + const key = normalizeConfigKey(getRequiredString(args, "key")); + const value = getRequiredString(args, "value"); + updateSavedTestLinkConfig(key, value); + printConfigValue(key, value); + return; + } + case "remove": { + const key = normalizeConfigKey(getRequiredString(args, "key")); + removeSavedTestLinkConfig(key); + console.log(`${key} removed`); + return; + } + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerProjectCommands(parser: Argv): Argv { + return parser.command( + "projects ", + "测试项目操作:list", + (command) => + command.positional("action", { + choices: ["list"] as const, + describe: "操作类型", + }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult(await api.getTestProjects()); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerCaseCommands(parser: Argv): Argv { + return parser.command( + "cases ", + "测试用例操作:view / create / update / delete / list-in-suite", + (command) => + command + .positional("action", { + choices: ["view", "create", "update", "delete", "list-in-suite"] as const, + describe: "操作类型", + }) + .option("testCaseId", { type: "string", describe: "测试用例 ID 或外部 ID" }) + .option("suiteId", { type: "string", describe: "测试套件 ID" }) + .option("data", { type: "string", describe: 'JSON 对象,例如 \'{"name":"用例名"}\'' }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "view": + printJsonResult(await api.getTestCase(getRequiredString(args, "testCaseId"))); + return; + case "create": + printJsonResult(await api.createTestCase(getJsonObject(args, "data"))); + return; + case "update": + printJsonResult( + await api.updateTestCase( + getRequiredString(args, "testCaseId"), + getJsonObject(args, "data"), + ), + ); + return; + case "delete": + printJsonResult(await api.deleteTestCase(getRequiredString(args, "testCaseId"))); + return; + case "list-in-suite": + printJsonResult(await api.getTestCasesForTestSuite(getRequiredString(args, "suiteId"))); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerSuiteCommands(parser: Argv): Argv { + return parser.command( + "suites ", + "测试套件操作:list / view / create / update", + (command) => + command + .positional("action", { + choices: ["list", "view", "create", "update"] as const, + describe: "操作类型", + }) + .option("projectId", { type: "string", describe: "测试项目 ID" }) + .option("suiteId", { type: "string", describe: "测试套件 ID" }) + .option("suiteName", { type: "string", describe: "测试套件名称" }) + .option("details", { type: "string", describe: "测试套件描述" }) + .option("parentId", { type: "string", describe: "父级测试套件 ID" }) + .option("data", { type: "string", describe: 'JSON 对象,例如 \'{"name":"套件名"}\'' }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult(await api.getTestSuites(getRequiredString(args, "projectId"))); + return; + case "view": + printJsonResult(await api.getTestSuiteByID(getRequiredString(args, "suiteId"))); + return; + case "create": + printJsonResult( + await api.createTestSuite( + getRequiredString(args, "projectId"), + getRequiredString(args, "suiteName"), + getString(args, "details") || "", + getString(args, "parentId"), + ), + ); + return; + case "update": + printJsonResult( + await api.updateTestSuite( + getRequiredString(args, "suiteId"), + getRequiredString(args, "projectId"), + getJsonObject(args, "data") ?? { + name: getString(args, "suiteName"), + details: getString(args, "details"), + }, + ), + ); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerPlanCommands(parser: Argv): Argv { + return parser.command( + "plans ", + "测试计划操作:list / create / delete / list-cases / add-case", + (command) => + command + .positional("action", { + choices: ["list", "create", "delete", "list-cases", "add-case"] as const, + describe: "操作类型", + }) + .option("projectId", { type: "string", describe: "测试项目 ID" }) + .option("planId", { type: "string", describe: "测试计划 ID" }) + .option("data", { type: "string", describe: "JSON 对象" }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult(await api.getTestPlans(getRequiredString(args, "projectId"))); + return; + case "create": + printJsonResult(await api.createTestPlan(getJsonObject(args, "data"))); + return; + case "delete": + printJsonResult(await api.deleteTestPlan(getRequiredString(args, "planId"))); + return; + case "list-cases": + printJsonResult(await api.getTestCasesForTestPlan(getRequiredString(args, "planId"))); + return; + case "add-case": + printJsonResult(await api.addTestCaseToTestPlan(getJsonObject(args, "data"))); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerBuildCommands(parser: Argv): Argv { + return parser.command( + "builds ", + "构建操作:list / create / close", + (command) => + command + .positional("action", { + choices: ["list", "create", "close"] as const, + describe: "操作类型", + }) + .option("planId", { type: "string", describe: "测试计划 ID" }) + .option("buildId", { type: "string", describe: "构建 ID" }) + .option("data", { type: "string", describe: "JSON 对象" }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult(await api.getBuilds(getRequiredString(args, "planId"))); + return; + case "create": + printJsonResult(await api.createBuild(getJsonObject(args, "data"))); + return; + case "close": + printJsonResult(await api.closeBuild(getRequiredString(args, "buildId"))); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerExecutionCommands(parser: Argv): Argv { + return parser.command( + "executions ", + "执行结果操作:list / create", + (command) => + command + .positional("action", { + choices: ["list", "create"] as const, + describe: "操作类型", + }) + .option("planId", { type: "string", describe: "测试计划 ID" }) + .option("buildId", { type: "string", describe: "构建 ID" }) + .option("data", { type: "string", describe: "JSON 对象" }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult( + await api.getTestExecutions( + getRequiredString(args, "planId"), + getString(args, "buildId"), + ), + ); + return; + case "create": + printJsonResult(await api.createTestExecution(getJsonObject(args, "data"))); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +function registerRequirementCommands(parser: Argv): Argv { + return parser.command( + "requirements ", + "需求操作:list / view", + (command) => + command + .positional("action", { + choices: ["list", "view"] as const, + describe: "操作类型", + }) + .option("projectId", { type: "string", describe: "测试项目 ID" }) + .option("requirementId", { type: "string", describe: "需求 ID" }), + async (args: CommandArgs) => { + const api = getClient(args); + const action = getRequiredString(args, "action"); + + switch (action) { + case "list": + printJsonResult(await api.getRequirements(getRequiredString(args, "projectId"))); + return; + case "view": + printJsonResult( + await api.getRequirement( + getRequiredString(args, "requirementId"), + getRequiredString(args, "projectId"), + ), + ); + return; + default: + throw new Error(`未知操作类型: ${action}`); + } + }, + ); +} + +async function runCli(): Promise { + let parser = addConnectionOptions(yargs(hideBin(process.argv)).scriptName("testlink")); + parser = registerConfigCommands(parser); + parser = registerProjectCommands(parser); + parser = registerCaseCommands(parser); + parser = registerSuiteCommands(parser); + parser = registerPlanCommands(parser); + parser = registerBuildCommands(parser); + parser = registerExecutionCommands(parser); + parser = registerRequirementCommands(parser); + + await parser + .demandCommand(1, "请指定一个命令") + .strict() + .help() + .alias("h", "help") + .version() + .alias("v", "version") + .parseAsync(); +} + +runCli().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/packages/testlink-mcp/src/config.ts b/packages/testlink-mcp/src/config.ts new file mode 100644 index 0000000..94223de --- /dev/null +++ b/packages/testlink-mcp/src/config.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface TestLinkConnectionOptions { + url?: string; + apiKey?: string; +} + +export type TestLinkConfigKey = keyof TestLinkConnectionOptions; +export type TestLinkConfigSource = "argument" | "config" | "env" | "unset"; + +export interface TestLinkConfigInspection { + configFile: string; + values: TestLinkConnectionOptions; + sources: Record; +} + +export const configKeys = ["url", "apiKey"] as const; + +export function getTestLinkConfigFilePath(): string { + if (process.env.TESTLINK_CONFIG_FILE) { + return process.env.TESTLINK_CONFIG_FILE; + } + + const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(configHome, "testlink", "config.json"); +} + +function toConfigValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : undefined; +} + +export function readSavedTestLinkConfig(): TestLinkConnectionOptions { + const configFile = getTestLinkConfigFilePath(); + if (!fs.existsSync(configFile)) { + return {}; + } + + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`配置文件格式错误: ${configFile}`); + } + + const rawConfig = parsed as Record; + return { + url: toConfigValue(rawConfig.url), + apiKey: toConfigValue(rawConfig.apiKey), + }; +} + +export function writeSavedTestLinkConfig(config: TestLinkConnectionOptions): void { + const configFile = getTestLinkConfigFilePath(); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync(`${configFile}.tmp`, `${JSON.stringify(config, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(`${configFile}.tmp`, configFile); + fs.chmodSync(configFile, 0o600); +} + +function resolveConfigValue( + argumentValue: string | undefined, + savedValue: string | undefined, + envValue: string | undefined, +): { value?: string; source: TestLinkConfigSource } { + if (argumentValue) return { value: argumentValue, source: "argument" }; + if (savedValue) return { value: savedValue, source: "config" }; + if (envValue) return { value: envValue, source: "env" }; + return { source: "unset" }; +} + +export function inspectTestLinkConfig( + options: TestLinkConnectionOptions, +): TestLinkConfigInspection { + const savedConfig = readSavedTestLinkConfig(); + const url = resolveConfigValue(options.url, savedConfig.url, process.env.TESTLINK_URL); + const apiKey = resolveConfigValue( + options.apiKey, + savedConfig.apiKey, + process.env.TESTLINK_API_KEY, + ); + + return { + configFile: getTestLinkConfigFilePath(), + values: { + url: url.value, + apiKey: apiKey.value, + }, + sources: { + url: url.source, + apiKey: apiKey.source, + }, + }; +} + +export function resolveTestLinkConfig( + options: TestLinkConnectionOptions, +): Required { + const inspection = inspectTestLinkConfig(options); + + if (!inspection.values.url || !inspection.values.apiKey) { + throw new Error( + [ + "请通过命令行参数、本地配置或环境变量提供 TestLink 连接配置:", + "--url / config url / TESTLINK_URL - TestLink 服务地址", + "--apiKey / config apiKey / TESTLINK_API_KEY - TestLink API Key", + `配置文件: ${inspection.configFile}`, + ].join("\n"), + ); + } + + return inspection.values as Required; +} + +export function updateSavedTestLinkConfig( + key: TestLinkConfigKey, + value: string, +): TestLinkConnectionOptions { + if (!configKeys.includes(key)) { + throw new Error(`不支持的配置项: ${key}`); + } + + const nextConfig = { + ...readSavedTestLinkConfig(), + [key]: value, + }; + writeSavedTestLinkConfig(nextConfig); + return nextConfig; +} + +export function removeSavedTestLinkConfig(key: TestLinkConfigKey): TestLinkConnectionOptions { + if (!configKeys.includes(key)) { + throw new Error(`不支持的配置项: ${key}`); + } + + const nextConfig = { + ...readSavedTestLinkConfig(), + [key]: undefined, + }; + writeSavedTestLinkConfig(nextConfig); + return nextConfig; +} diff --git a/packages/testlink-mcp/src/index.ts b/packages/testlink-mcp/src/index.ts new file mode 100644 index 0000000..baa8505 --- /dev/null +++ b/packages/testlink-mcp/src/index.ts @@ -0,0 +1,529 @@ +#!/usr/bin/env node +/** + * TestLink MCP Server + * 提供 TestLink 测试资产的常用操作给 AI 助手使用。 + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import dotenv from "dotenv"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { TestLinkAPI } from "./api.js"; +import { resolveTestLinkConfig, type TestLinkConnectionOptions } from "./config.js"; + +export { TestLinkAPI } from "./api.js"; +export type { TestLinkApiOptions } from "./api.js"; + +dotenv.config({ quiet: true }); + +type CommandArgs = Record; + +type TestLinkMcpOptions = TestLinkConnectionOptions; + +function getString(args: CommandArgs, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" ? value : undefined; +} + +function addConnectionOptions(parser: ReturnType): ReturnType { + return parser + .option("url", { + type: "string", + describe: "服务地址;", + }) + .option("apiKey", { + type: "string", + alias: "api-key", + describe: "API Key;", + }); +} + +export function resolveTestLinkMcpOptions( + options: TestLinkMcpOptions, +): Required { + return resolveTestLinkConfig(options); +} + +function parseMcpOptions(): TestLinkMcpOptions { + const args = addConnectionOptions( + yargs(hideBin(process.argv)).scriptName("npx @acehubert/testlink-mcp@latest"), + ) + .help(false) + .version(false) + .exitProcess(false) + .showHelpOnFail(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseSync() as CommandArgs; + + return { + url: getString(args, "url"), + apiKey: getString(args, "apiKey"), + }; +} + +const tools: Tool[] = [ + { + name: "read_test_case", + description: "读取 TestLink 测试用例,支持数字 ID 或外部 ID(如 PREFIX-123)", + inputSchema: { + type: "object", + properties: { + test_case_id: { type: "string", description: "测试用例 ID" }, + }, + required: ["test_case_id"], + }, + }, + { + name: "update_test_case", + description: "更新 TestLink 测试用例", + inputSchema: { + type: "object", + properties: { + test_case_id: { type: "string", description: "测试用例 ID" }, + data: { + type: "object", + description: "测试用例更新内容", + properties: { + name: { type: "string" }, + summary: { type: "string" }, + preconditions: { type: "string" }, + steps: { type: "array" }, + importance: { type: "number" }, + execution_type: { type: "number" }, + status: { type: "number" }, + }, + }, + }, + required: ["test_case_id", "data"], + }, + }, + { + name: "create_test_case", + description: "创建 TestLink 测试用例", + inputSchema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + testprojectid: { type: "string", description: "测试项目 ID" }, + testsuiteid: { type: "string", description: "测试套件 ID" }, + name: { type: "string", description: "测试用例名称" }, + authorlogin: { type: "string", description: "作者账号" }, + summary: { type: "string" }, + steps: { type: "array" }, + importance: { type: "number" }, + execution_type: { type: "number" }, + status: { type: "number" }, + }, + required: ["testprojectid", "testsuiteid", "name", "authorlogin"], + }, + }, + required: ["data"], + }, + }, + { + name: "delete_test_case", + description: "删除测试用例。TestLink XML-RPC 无直接删除能力,实际会标记为 obsolete", + inputSchema: { + type: "object", + properties: { + test_case_id: { type: "string", description: "测试用例 ID" }, + }, + required: ["test_case_id"], + }, + }, + { + name: "list_projects", + description: "列出所有 TestLink 测试项目", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "list_test_suites", + description: "列出测试项目下的一级测试套件", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "测试项目 ID" }, + }, + required: ["project_id"], + }, + }, + { + name: "read_test_suite", + description: "读取测试套件详情", + inputSchema: { + type: "object", + properties: { + suite_id: { type: "string", description: "测试套件 ID" }, + }, + required: ["suite_id"], + }, + }, + { + name: "list_test_cases_in_suite", + description: "列出测试套件中的测试用例", + inputSchema: { + type: "object", + properties: { + suite_id: { type: "string", description: "测试套件 ID" }, + }, + required: ["suite_id"], + }, + }, + { + name: "create_test_suite", + description: "创建测试套件", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "测试项目 ID" }, + suite_name: { type: "string", description: "测试套件名称" }, + details: { type: "string", description: "测试套件描述" }, + parent_id: { type: "string", description: "父级测试套件 ID" }, + }, + required: ["project_id", "suite_name"], + }, + }, + { + name: "update_test_suite", + description: "更新测试套件", + inputSchema: { + type: "object", + properties: { + suite_id: { type: "string", description: "测试套件 ID" }, + project_id: { type: "string", description: "测试项目 ID" }, + data: { + type: "object", + properties: { + name: { type: "string" }, + details: { type: "string" }, + }, + }, + }, + required: ["suite_id", "project_id", "data"], + }, + }, + { + name: "list_test_plans", + description: "列出测试项目下的测试计划", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "测试项目 ID" }, + }, + required: ["project_id"], + }, + }, + { + name: "create_test_plan", + description: "创建测试计划", + inputSchema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + project_id: { type: "string", description: "项目名称或前缀" }, + name: { type: "string", description: "测试计划名称" }, + notes: { type: "string" }, + active: { type: "number" }, + is_public: { type: "number" }, + }, + required: ["project_id", "name"], + }, + }, + required: ["data"], + }, + }, + { + name: "delete_test_plan", + description: "删除测试计划", + inputSchema: { + type: "object", + properties: { + plan_id: { type: "string", description: "测试计划 ID" }, + }, + required: ["plan_id"], + }, + }, + { + name: "get_test_cases_for_test_plan", + description: "列出测试计划中的测试用例", + inputSchema: { + type: "object", + properties: { + plan_id: { type: "string", description: "测试计划 ID" }, + }, + required: ["plan_id"], + }, + }, + { + name: "add_test_case_to_test_plan", + description: "将测试用例加入测试计划", + inputSchema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + testcaseid: { type: "string", description: "测试用例 ID 或外部 ID" }, + testplanid: { type: "string", description: "测试计划 ID" }, + testprojectid: { type: "string", description: "测试项目 ID" }, + version: { type: "number" }, + platformid: { type: "string" }, + urgency: { type: "number" }, + overwrite: { type: "boolean" }, + }, + required: ["testcaseid", "testplanid", "testprojectid"], + }, + }, + required: ["data"], + }, + }, + { + name: "list_builds", + description: "列出测试计划下的构建", + inputSchema: { + type: "object", + properties: { + plan_id: { type: "string", description: "测试计划 ID" }, + }, + required: ["plan_id"], + }, + }, + { + name: "create_build", + description: "创建构建", + inputSchema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + plan_id: { type: "string", description: "测试计划 ID" }, + name: { type: "string", description: "构建名称" }, + notes: { type: "string" }, + active: { type: "number" }, + open: { type: "number" }, + release_date: { type: "string" }, + }, + required: ["plan_id", "name"], + }, + }, + required: ["data"], + }, + }, + { + name: "close_build", + description: "关闭构建", + inputSchema: { + type: "object", + properties: { + build_id: { type: "string", description: "构建 ID" }, + }, + required: ["build_id"], + }, + }, + { + name: "read_test_execution", + description: "读取测试计划执行结果", + inputSchema: { + type: "object", + properties: { + plan_id: { type: "string", description: "测试计划 ID" }, + build_id: { type: "string", description: "构建 ID" }, + }, + required: ["plan_id"], + }, + }, + { + name: "create_test_execution", + description: "记录测试执行结果", + inputSchema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + test_case_id: { type: "string", description: "测试用例 ID" }, + plan_id: { type: "string", description: "测试计划 ID" }, + build_id: { type: "string", description: "构建 ID" }, + status: { type: "string", description: "执行状态,如 p/f/b" }, + notes: { type: "string" }, + platform_id: { type: "string" }, + steps: { type: "array" }, + }, + required: ["test_case_id", "plan_id", "build_id", "status"], + }, + }, + required: ["data"], + }, + }, + { + name: "list_requirements", + description: "列出测试项目下的需求", + inputSchema: { + type: "object", + properties: { + project_id: { type: "string", description: "测试项目 ID" }, + }, + required: ["project_id"], + }, + }, + { + name: "get_requirement", + description: "读取需求详情", + inputSchema: { + type: "object", + properties: { + requirement_id: { type: "string", description: "需求 ID" }, + project_id: { type: "string", description: "测试项目 ID" }, + }, + required: ["requirement_id", "project_id"], + }, + }, +]; + +function toJsonText(result: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; +} + +function getRequiredArg(args: CommandArgs, key: string): string { + const value = args[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`缺少必要参数: ${key}`); + } + return value; +} + +export function createTestLinkMcpServer(testlinkAPI: TestLinkAPI): Server { + const server = new Server( + { + name: "@acehubert/testlink-mcp", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const args = (request.params.arguments ?? {}) as CommandArgs; + + try { + switch (request.params.name) { + case "read_test_case": + return toJsonText(await testlinkAPI.getTestCase(getRequiredArg(args, "test_case_id"))); + case "update_test_case": + return toJsonText( + await testlinkAPI.updateTestCase(getRequiredArg(args, "test_case_id"), args.data), + ); + case "create_test_case": + return toJsonText(await testlinkAPI.createTestCase(args.data)); + case "delete_test_case": + return toJsonText(await testlinkAPI.deleteTestCase(getRequiredArg(args, "test_case_id"))); + case "list_projects": + return toJsonText(await testlinkAPI.getTestProjects()); + case "list_test_suites": + return toJsonText(await testlinkAPI.getTestSuites(getRequiredArg(args, "project_id"))); + case "read_test_suite": + return toJsonText(await testlinkAPI.getTestSuiteByID(getRequiredArg(args, "suite_id"))); + case "list_test_cases_in_suite": + return toJsonText( + await testlinkAPI.getTestCasesForTestSuite(getRequiredArg(args, "suite_id")), + ); + case "create_test_suite": + return toJsonText( + await testlinkAPI.createTestSuite( + getRequiredArg(args, "project_id"), + getRequiredArg(args, "suite_name"), + getString(args, "details") || "", + getString(args, "parent_id"), + ), + ); + case "update_test_suite": + return toJsonText( + await testlinkAPI.updateTestSuite( + getRequiredArg(args, "suite_id"), + getRequiredArg(args, "project_id"), + args.data, + ), + ); + case "list_test_plans": + return toJsonText(await testlinkAPI.getTestPlans(getRequiredArg(args, "project_id"))); + case "create_test_plan": + return toJsonText(await testlinkAPI.createTestPlan(args.data)); + case "delete_test_plan": + return toJsonText(await testlinkAPI.deleteTestPlan(getRequiredArg(args, "plan_id"))); + case "get_test_cases_for_test_plan": + return toJsonText( + await testlinkAPI.getTestCasesForTestPlan(getRequiredArg(args, "plan_id")), + ); + case "add_test_case_to_test_plan": + return toJsonText(await testlinkAPI.addTestCaseToTestPlan(args.data)); + case "list_builds": + return toJsonText(await testlinkAPI.getBuilds(getRequiredArg(args, "plan_id"))); + case "create_build": + return toJsonText(await testlinkAPI.createBuild(args.data)); + case "close_build": + return toJsonText(await testlinkAPI.closeBuild(getRequiredArg(args, "build_id"))); + case "read_test_execution": + return toJsonText( + await testlinkAPI.getTestExecutions( + getRequiredArg(args, "plan_id"), + getString(args, "build_id"), + ), + ); + case "create_test_execution": + return toJsonText(await testlinkAPI.createTestExecution(args.data)); + case "list_requirements": + return toJsonText(await testlinkAPI.getRequirements(getRequiredArg(args, "project_id"))); + case "get_requirement": + return toJsonText( + await testlinkAPI.getRequirement( + getRequiredArg(args, "requirement_id"), + getRequiredArg(args, "project_id"), + ), + ); + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + isError: true, + }; + } + }); + + return server; +} + +async function main(): Promise { + const options = resolveTestLinkMcpOptions(parseMcpOptions()); + const testlinkAPI = new TestLinkAPI(options.url, options.apiKey); + const server = createTestLinkMcpServer(testlinkAPI); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : "MCP Server 启动失败"); + process.exit(1); +}); diff --git a/packages/testlink-mcp/tsconfig.json b/packages/testlink-mcp/tsconfig.json new file mode 100644 index 0000000..1245f0f --- /dev/null +++ b/packages/testlink-mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "incremental": false, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "declaration": true, + "sourceMap": false, + "inlineSources": false, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/skills/apifox-cli/SKILL.md b/skills/apifox-cli/SKILL.md new file mode 100644 index 0000000..317aa1e --- /dev/null +++ b/skills/apifox-cli/SKILL.md @@ -0,0 +1,130 @@ +--- +name: apifox-cli +description: Use the apifox CLI to read, refresh, and inspect Apifox/OpenAPI caches directly from the terminal for one-off queries, scripting, and local debugging. +--- + +The `apifox` CLI exposes Apifox/OpenAPI document access directly in the +terminal. Use it for one-off queries, scripted checks, and local debugging +without starting an MCP client first. + +## Setup + +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. + +## 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 + cache-related work. + +## Command Usage + +```bash +apifox [arguments] [flags] +``` + +Example with explicit arguments: + +```bash +apifox cache info \ + --projectId 12345 \ + --token "your_access_token" +``` + +Prefer putting only the token in the environment: + +```bash +export APIFOX_ACCESS_TOKEN="your_access_token" +``` + +Use built-in help when needed: + +```bash +apifox --help +apifox --version +apifox oas --help +apifox refs --help +apifox cache --help +``` + +## OpenAPI Documents + +```bash +apifox oas view --projectId 12345 +apifox oas refresh --projectId 12345 +``` + +For local or remote OAS sources: + +```bash +apifox oas view --oas /tmp/openapi.json +apifox oas refresh --oas https://example.com/openapi.json +``` + +## Referenced Resources + +Repeat `--path` to read multiple `$ref` files: + +```bash +apifox refs read \ + --projectId 12345 \ + --path /paths/_users.json \ + --path /components/schemas/User.json +``` + +## Cache + +```bash +apifox cache info --projectId 12345 +apifox cache info --oas /tmp/openapi.json +``` + +`cache info` returns JSON including: + +- `cacheDir` +- `cacheFile` +- `exists` +- `source` +- `lastUpdatedAt` + +## Scripting Patterns + +CLI output is JSON and works well with `jq`: + +```bash +apifox oas view --oas /tmp/openapi.json | jq '.paths' +``` + +A common incremental workflow: + +```bash +apifox oas refresh --projectId 12345 +apifox oas view --projectId 12345 +apifox refs read --projectId 12345 --path /components/schemas/index.json +``` + +## Safety + +- Do not put `APIFOX_ACCESS_TOKEN` into repository files, shell history, or + logs. +- When only a subset of the document is needed, read specific `$ref` files + instead of expanding everything. +- For large specs, read the main index first and then follow only relevant + references. + +## Troubleshooting + +- **Missing required arguments**: Confirm the command includes `--projectId`, + `--siteId`, or `--oas`. +- **Project read failure**: Confirm `APIFOX_ACCESS_TOKEN` matches the target + project. +- **Cache path is not writable**: Set `--dataLocation` or + `APIFOX_DATA_LOCATION`. +- **Referenced file missing**: Run `oas refresh` and then try reading the file + again. diff --git a/skills/apifox-cli/references/installation.md b/skills/apifox-cli/references/installation.md new file mode 100644 index 0000000..f291b3e --- /dev/null +++ b/skills/apifox-cli/references/installation.md @@ -0,0 +1,55 @@ +# Installation + +Install the package globally to make both `apifox` and `apifox-mcp` available: + +```sh +npm i @acehubert/apifox-mcp@latest -g +apifox --version +``` + +## Configuration + +Prefer keeping only the token in the environment: + +```sh +export APIFOX_ACCESS_TOKEN="your_access_token" +``` + +Source selection must be passed explicitly per command, for example: + +```sh +apifox cache info --projectId 12345 +apifox cache info --siteId 67890 +apifox cache info --oas /path/to/openapi.json +``` + +You can also pass the token directly: + +```sh +apifox cache info \ + --projectId 12345 \ + --token "your_access_token" +``` + +## Optional Configuration + +```sh +export APIFOX_API_BASE_URL="https://api.apifox.com" +export APIFOX_API_VERSION="2024-03-28" +export APIFOX_API_PAGE_SIZE="300" +export APIFOX_DATA_LOCATION="/tmp" +``` + +- `APIFOX_API_BASE_URL`: override for private deployment environments +- `APIFOX_API_PAGE_SIZE`: controls path pagination size +- `APIFOX_DATA_LOCATION`: controls the cache root directory + +## Troubleshooting + +- **Command not found**: Ensure the global npm `bin` directory is in `PATH`, + then restart your terminal. +- **Permission errors**: Avoid `sudo`; prefer `nvm` or a custom npm global + directory. +- **Wrong version**: Run `npm uninstall -g @acehubert/apifox-mcp` and + reinstall. +- **Cache path not writable**: Set `APIFOX_DATA_LOCATION` to a writable path. diff --git a/skills/apifox/SKILL.md b/skills/apifox/SKILL.md new file mode 100644 index 0000000..82cf80d --- /dev/null +++ b/skills/apifox/SKILL.md @@ -0,0 +1,121 @@ +--- +name: apifox +description: Use Apifox MCP tools to efficiently read, refresh, and inspect Apifox/OpenAPI caches and $ref resources for Apifox projects, docs sites, or local/remote OAS files. +--- + +## Core Concepts + +**Server configuration**: The MCP server is provided by `@acehubert/apifox-mcp`. +Source selection must be passed explicitly as startup arguments. Provide one of: + +- `--projectId` +- `--siteId` +- `--oas` + +When the source is an Apifox project, `APIFOX_ACCESS_TOKEN` is also required. +Example MCP configuration: + +```json +{ + "mcpServers": { + "api-docs": { + "command": "npx", + "args": ["-y", "@acehubert/apifox-mcp@latest", "--projectId=12345"], + "env": { + "APIFOX_ACCESS_TOKEN": "your_access_token" + } + } + } +} +``` + +**Source types**: + +- `project`: export OpenAPI from an Apifox project +- `doc-site`: export MCP data from an Apifox docs site +- `oas`: read a local or remote OpenAPI file directly + +**Cache model**: The tool stores a local cache of the full OpenAPI document and +splits `paths` and `components` into `$ref` files. For large specs, read the +main index first and then fetch only the referenced files you need. + +**JSON output**: Tool responses are JSON text. Inspect fields, source settings, +and `$ref` paths before using the result in follow-up work. + +## Workflow Patterns + +### Before reading documentation + +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. +5. Then use `read_apifox_oas` or `read_apifox_oas_ref_resources`. + +### Document reading + +- Use `read_apifox_oas` for the main document index. +- When the index contains `$ref`, use `read_apifox_oas_ref_resources` to load + those files by path. +- For large specs, prefer reading only the relevant referenced files instead of + expanding everything. + +### Cache refresh + +- Use `refresh_apifox_oas` when the user asks for the latest API documentation. +- Refresh first whenever the local cache may be stale. + +### Source selection + +- Prefer `project` when the user provides an Apifox project ID. +- Use `doc-site` when the user provides a docs site ID. +- Use `oas` when the user gives a Swagger/OpenAPI URL or local file path. +- Local and remote OAS sources do not require `APIFOX_ACCESS_TOKEN`. + +## Tool Selection + +- **Main document**: `read_apifox_oas` +- **Referenced resources**: `read_apifox_oas_ref_resources` +- **Refresh cache**: `refresh_apifox_oas` +- **Cache details**: `get_apifox_cache_info` + +## Efficient Retrieval + +- Read the main index first, then the required `$ref` files. +- Only load the path or schema resources needed for the current task. +- If the main document contains `x-pagination`, continue through the paginated + path references instead of assuming all paths are inline. +- Reuse verified `$ref` paths instead of guessing filenames. + +## Parallel Execution + +Parallelize independent read-only work such as: + +- loading multiple `$ref` files +- reading multiple schemas or paths after a refresh + +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. + +## Safety + +- Never expose `APIFOX_ACCESS_TOKEN` in user-facing output. +- Before loading many `$ref` files, confirm that full expansion is actually + necessary. +- When the user only needs a small subset of endpoints or schemas, avoid + over-fetching. + +## Troubleshooting + +- **Missing source config**: Confirm the startup command includes + `--projectId`, `--siteId`, or `--oas`. +- **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. +- **Local cache issues**: Inspect `get_apifox_cache_info`, then refresh the + cache. +- **Referenced file missing**: The cache may be stale or the `$ref` paths may + have changed; refresh and try again. diff --git a/skills/testlink-cli/SKILL.md b/skills/testlink-cli/SKILL.md new file mode 100644 index 0000000..b45f680 --- /dev/null +++ b/skills/testlink-cli/SKILL.md @@ -0,0 +1,181 @@ +--- +name: testlink-cli +description: Use this skill to query, create, and maintain TestLink projects, suites, cases, plans, builds, execution results, and requirements through the testlink CLI. +--- + +The `testlink` CLI exposes TestLink operations directly in the terminal. Use it +for one-off queries, batch checks, and scripted maintenance without starting an +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._ + +## 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 + 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 + 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 + state, title, content, assignment, or execution result changed as expected. + +## Command Usage + +```bash +testlink [arguments] [flags] +``` + +Common global connection options: + +```bash +testlink projects list \ + --url "https://testlink.example.com" \ + --apiKey "your_api_key" +``` + +Prefer configuring connection details in the shell environment: + +```bash +export TESTLINK_URL="https://testlink.example.com" +export TESTLINK_API_KEY="your_api_key" +``` + +Use `--help` on any command: + +```bash +testlink --help +testlink --version +testlink cases --help +testlink suites --help +testlink plans --help +``` + +## Projects + +```bash +testlink projects list +``` + +Use returned project IDs for suite, plan, and requirement operations. + +## Test Suites + +```bash +testlink suites list --projectId 1 +testlink suites view --suiteId 10 + +testlink suites create \ + --projectId 1 \ + --suiteName "Login" \ + --details "Login-related test suites" + +testlink suites create \ + --projectId 1 \ + --suiteName "Password Login" \ + --parentId 10 + +testlink suites update \ + --suiteId 10 \ + --projectId 1 \ + --data '{"name":"Login Suite","details":"Updated details"}' +``` + +## Test Cases + +`data` must be passed as a JSON object string. + +```bash +testlink cases list-in-suite --suiteId 10 +testlink cases view --testCaseId PREFIX-123 + +testlink cases create \ + --data '{"testprojectid":"1","testsuiteid":"10","name":"Successful login","authorlogin":"qa","summary":"Verify login succeeds","steps":[{"step_number":1,"actions":"Enter valid credentials","expected_results":"Login succeeds"}]}' + +testlink cases update \ + --testCaseId PREFIX-123 \ + --data '{"summary":"Updated summary","importance":2}' + +testlink cases delete --testCaseId PREFIX-123 +``` + +`cases delete` marks the case obsolete; it does not physically delete the case. + +## Test Plans + +```bash +testlink plans list --projectId 1 + +testlink plans create \ + --data '{"project_id":"PROJECT_PREFIX","name":"Regression Plan","notes":"Main regression plan"}' + +testlink plans list-cases --planId 20 + +testlink plans add-case \ + --data '{"testcaseid":"PREFIX-123","testplanid":"20","testprojectid":"1","version":1}' + +testlink plans delete --planId 20 +``` + +## Builds + +```bash +testlink builds list --planId 20 + +testlink builds create \ + --data '{"plan_id":"20","name":"2026.04.24","notes":"Release validation build"}' + +testlink builds close --buildId 30 +``` + +## Executions + +```bash +testlink executions list --planId 20 +testlink executions list --planId 20 --buildId 30 + +testlink executions create \ + --data '{"test_case_id":"PREFIX-123","plan_id":"20","build_id":"30","status":"p","notes":"Passed on staging"}' +``` + +Common TestLink status values are `p` for passed, `f` for failed, and `b` for +blocked. Confirm project-specific status rules when in doubt. + +## Requirements + +```bash +testlink requirements list --projectId 1 +testlink requirements view --projectId 1 --requirementId 100 +``` + +## Scripting Patterns + +Read commands output JSON and work well with `jq` filters: + +```bash +testlink cases list-in-suite --suiteId 10 \ + | jq '.[] | {id, external_id, name}' +``` + +Before batch writes, perform inspection-style queries first and confirm each +target ID: + +```bash +testlink cases view --testCaseId PREFIX-123 +testlink cases update --testCaseId PREFIX-123 --data '{"importance":3}' +testlink cases view --testCaseId PREFIX-123 +``` + +## Safety + +- Do not write `TESTLINK_API_KEY` into repository files, scripts, or logs. +- Before production writes, inspect the target object and explicitly confirm + the ID, current state, and impact scope. +- Get explicit user confirmation before batch updates, plan deletion, build + closing, execution result updates, or marking test cases obsolete. diff --git a/skills/testlink-cli/references/installation.md b/skills/testlink-cli/references/installation.md new file mode 100644 index 0000000..78ca4ca --- /dev/null +++ b/skills/testlink-cli/references/installation.md @@ -0,0 +1,37 @@ +# Installation + +Install the package globally to make the `testlink` command available. You only +need to do this the first time you use it. + +```sh +npm i @acehubert/testlink-mcp@latest -g +testlink --version # check if install worked +``` + +## Configuration + +Prefer environment variables for connection details: + +```sh +export TESTLINK_URL="https://testlink.example.com" +export TESTLINK_API_KEY="your_api_key" +``` + +You can also pass connection flags per command: + +```sh +testlink projects list \ + --url "https://testlink.example.com" \ + --apiKey "your_api_key" +``` + +## Troubleshooting + +- **Command not found:** If `testlink` is not recognized, ensure your global + npm `bin` directory is in your system's `PATH`. Restart your terminal or + source your shell configuration file, such as `.bashrc` or `.zshrc`. +- **Permission errors:** If you encounter `EACCES` or permission errors during + installation, avoid using `sudo`. Instead, use a node version manager like + `nvm`, or configure npm to use a different global directory. +- **Old version running:** Run `npm uninstall -g @acehubert/testlink-mcp` before + reinstalling, or ensure the latest version is being picked up by your path. diff --git a/skills/testlink/SKILL.md b/skills/testlink/SKILL.md new file mode 100644 index 0000000..11a0fb1 --- /dev/null +++ b/skills/testlink/SKILL.md @@ -0,0 +1,151 @@ +--- +name: testlink +description: Uses TestLink via MCP for efficient test project, test suite, test case, test plan, build, execution result, and requirement operations through the configured @acehubert/testlink-mcp server tools. +--- + +## Core Concepts + +**Server configuration**: The MCP server is provided by `@acehubert/testlink-mcp` +and requires TestLink XML-RPC connection settings: `TESTLINK_URL` and +`TESTLINK_API_KEY`. Start it from an MCP client with `npx`: + +```json +{ + "mcpServers": { + "testlink": { + "command": "npx", + "args": ["-y", "@acehubert/testlink-mcp@latest"], + "env": { + "TESTLINK_URL": "https://testlink.example.com", + "TESTLINK_API_KEY": "your_api_key" + } + } + } +} +``` + +**ID-driven operations**: Most TestLink operations require stable IDs. Use list +and read tools first to identify the exact `project_id`, `suite_id`, +`test_case_id`, `plan_id`, `build_id`, or `requirement_id`. + +**Test case IDs**: Test case reads and writes support both numeric IDs such as +`123` and external IDs such as `PREFIX-123`. Prefer external IDs when users +provide them because they are easier to verify across TestLink screens. + +**Deletion semantics**: `delete_test_case` does not physically delete a test +case. TestLink XML-RPC does not expose a direct delete method through the +client, so the MCP server marks the test case as obsolete. + +**JSON output**: Tool responses are formatted JSON text. Inspect returned +fields before using IDs in follow-up write operations. + +## Workflow Patterns + +### Before writing TestLink data + +1. Identify the target project with `list_projects`. +2. Identify the target suite, plan, build, or requirement with the appropriate + list/read tool. +3. Inspect the current record with a read tool. +4. Confirm the ID, current state, and requested change. +5. Call the write tool. +6. Verify the result with another read/list call. + +### Test Projects + +- Use `list_projects` to discover available TestLink projects. +- Use project IDs from the response for suite, plan, and requirement queries. + +### Test Suites + +- Use `list_test_suites` with `project_id` to inspect top-level suites. +- Use `read_test_suite` before updating a suite. +- Use `create_test_suite` with `parent_id` only when creating nested suites. +- Use `list_test_cases_in_suite` to inspect suite coverage before adding or + changing test cases. + +### Test Cases + +- Use `read_test_case` before updating or marking a case obsolete. +- Use `create_test_case` with `testprojectid`, `testsuiteid`, `name`, and + `authorlogin`. +- Use structured `steps` arrays when creating or updating detailed cases. +- Use `delete_test_case` only when the user accepts that the case will be + marked obsolete. + +### Test Plans and Builds + +- Use `list_test_plans` to find plan IDs for a project. +- Use `get_test_cases_for_test_plan` before adding duplicate coverage. +- Use `add_test_case_to_test_plan` with `testcaseid`, `testplanid`, and + `testprojectid`. +- Use `list_builds` to find build IDs before recording execution results. +- Use `close_build` only after confirming no further executions should be + recorded for the build. + +### Executions + +- Use `read_test_execution` to inspect existing plan or build execution + results. +- Use `create_test_execution` with `test_case_id`, `plan_id`, `build_id`, and + `status`. +- Common TestLink status values are `p` for passed, `f` for failed, and `b` for + blocked. Confirm project-specific status rules when in doubt. + +### Requirements + +- Use `list_requirements` with `project_id` to find requirement IDs. +- Use `get_requirement` before linking decisions or reporting requirement + details. + +## Tool Selection + +- **Projects**: `list_projects` +- **Suites**: `list_test_suites`, `read_test_suite`, `create_test_suite`, + `update_test_suite` +- **Cases**: `read_test_case`, `create_test_case`, `update_test_case`, + `delete_test_case`, `list_test_cases_in_suite` +- **Plans**: `list_test_plans`, `create_test_plan`, `delete_test_plan`, + `get_test_cases_for_test_plan`, `add_test_case_to_test_plan` +- **Builds**: `list_builds`, `create_build`, `close_build` +- **Executions**: `read_test_execution`, `create_test_execution` +- **Requirements**: `list_requirements`, `get_requirement` + +## Efficient Data Retrieval + +- Start with the narrowest list tool that can identify the target ID. +- Prefer read tools after list tools because list responses can omit detail. +- Keep large `steps` payloads focused and avoid dumping broad test plan results + unless the user needs them. +- Reuse IDs from verified responses instead of guessing IDs from names. + +## Parallel Execution + +Independent read-only calls can run in parallel, such as listing projects, +plans, builds, and requirements. Keep dependent operations ordered: + +`list -> read -> create/update/delete/close/execute -> read/list` + +Do not run parallel writes against the same TestLink object, plan, build, or +suite. + +## Safety + +- Never expose `TESTLINK_API_KEY` in user-facing output. +- Get explicit user confirmation before bulk writes, mass execution result + updates, deleting plans, closing builds, or marking test cases obsolete. +- Inspect records before changing them, especially for update, delete, close, + and execution result operations. +- Preserve TestLink workflow semantics. Do not close builds or overwrite plan + assignments unless the user explicitly requests it. + +## Troubleshooting + +- **Server configuration errors**: Check that `TESTLINK_URL` and + `TESTLINK_API_KEY` are configured for the MCP server. +- **Connection refused or timeout**: Confirm the TestLink URL is reachable from + the MCP server environment. +- **Authentication failed**: Regenerate or verify the TestLink API key for the + configured account. +- **Object not found**: Re-run the relevant list/read tool and confirm whether + the ID should be numeric or external ID format. diff --git a/yarn.lock b/yarn.lock index 552852e..e79e316 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,35 @@ __metadata: version: 6 cacheKey: 8 +"@acehubert/apifox-mcp@workspace:packages/apifox-mcp": + version: 0.0.0-use.local + resolution: "@acehubert/apifox-mcp@workspace:packages/apifox-mcp" + dependencies: + "@modelcontextprotocol/sdk": ^1.25.2 + "@types/yargs": ^17.0.35 + dotenv: ^17.2.3 + yargs: ^17.7.2 + bin: + apifox: dist/cli.cjs + apifox-mcp: dist/index.cjs + languageName: unknown + linkType: soft + +"@acehubert/testlink-mcp@workspace:packages/testlink-mcp": + version: 0.0.0-use.local + resolution: "@acehubert/testlink-mcp@workspace:packages/testlink-mcp" + dependencies: + "@modelcontextprotocol/sdk": ^1.25.2 + "@types/yargs": ^17.0.35 + dotenv: ^17.2.3 + testlink-xmlrpc: ^3.0.0 + yargs: ^17.7.2 + bin: + testlink: dist/cli.cjs + testlink-mcp: dist/index.cjs + languageName: unknown + linkType: soft + "@acehubert/zentao-api@workspace:*, @acehubert/zentao-api@workspace:packages/zentao-api": version: 0.0.0-use.local resolution: "@acehubert/zentao-api@workspace:packages/zentao-api" @@ -10720,6 +10749,39 @@ __metadata: languageName: node linkType: hard +"root@workspace:.": + version: 0.0.0-use.local + resolution: "root@workspace:." + dependencies: + "@babel/runtime": ^7.28.4 + "@commitlint/cli": ^19.8.1 + "@commitlint/config-conventional": ^19.8.1 + "@instructure/cz-lerna-changelog": ^8.56.2 + "@types/jest": ^29.5.14 + "@types/node": ^18.0.0 + "@types/yargs": ^17.0.35 + "@typescript-eslint/eslint-plugin": ^8.31.1 + "@typescript-eslint/parser": ^8.31.1 + commitizen: ^4.3.1 + esbuild: ^0.27.2 + eslint: ^8.57.1 + eslint-config-prettier: ^10.1.2 + eslint-import-resolver-typescript: ^4.3.5 + eslint-plugin-import: ^2.31.0 + eslint-plugin-prettier: ^5.2.6 + jest: ^29.7.0 + lerna: ^8.2.1 + lerna-changelog: ^2.2.0 + lint-staged: ^15.5.0 + prettier: ^3.5.3 + rimraf: ^6.1.3 + ts-jest: ^29.2.5 + ts-node: ^10.9.2 + typescript: ^5.9.3 + yorkie: ^2.0.0 + languageName: unknown + linkType: soft + "router@npm:^2.2.0": version: 2.2.0 resolution: "router@npm:2.2.0" @@ -10822,6 +10884,13 @@ __metadata: languageName: node linkType: hard +"sax@npm:1.2.x": + version: 1.2.4 + resolution: "sax@npm:1.2.4" + checksum: d3df7d32b897a2c2f28e941f732c71ba90e27c24f62ee918bd4d9a8cfb3553f2f81e5493c7f0be94a11c1911b643a9108f231dd6f60df3fa9586b5d2e3e9e1fe + languageName: node + linkType: hard + "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -11618,6 +11687,15 @@ __metadata: languageName: node linkType: hard +"testlink-xmlrpc@npm:^3.0.0": + version: 3.0.0 + resolution: "testlink-xmlrpc@npm:3.0.0" + dependencies: + xmlrpc: 1.3.2 + checksum: d67f7e7fa97c515fe34a0531d536695d9bb9d8dcb95ca3db1e7376009541850b6574e3aad50c8e66cee2fc07856a71cb398d3e8caf5b5c8463aace7271345daa + languageName: node + linkType: hard + "text-extensions@npm:^1.0.0": version: 1.9.0 resolution: "text-extensions@npm:1.9.0" @@ -12691,6 +12769,23 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:8.2.x": + version: 8.2.2 + resolution: "xmlbuilder@npm:8.2.2" + checksum: 6e07fcee91aa77186302961e882c94a58d0a8d26f6cdeb637d2bc2cf9fb228a03d3214a6f9380846265c89a8536c19424468373f9e3f551c3be7a74819bd0777 + languageName: node + linkType: hard + +"xmlrpc@npm:1.3.2": + version: 1.3.2 + resolution: "xmlrpc@npm:1.3.2" + dependencies: + sax: 1.2.x + xmlbuilder: 8.2.x + checksum: c997d86ad282727fdfc429025377a91059f94c4d62708914fba542e82934b13921c3b5893840a456e954a3ce174fdd5bf83669aea1680462c03fb5bda1b5458a + languageName: node + linkType: hard + "xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" @@ -12819,38 +12914,6 @@ __metadata: languageName: node linkType: hard -"zentao@workspace:.": - version: 0.0.0-use.local - resolution: "zentao@workspace:." - dependencies: - "@babel/runtime": ^7.28.4 - "@commitlint/cli": ^19.8.1 - "@commitlint/config-conventional": ^19.8.1 - "@instructure/cz-lerna-changelog": ^8.56.2 - "@types/jest": ^29.5.14 - "@types/node": ^18.0.0 - "@typescript-eslint/eslint-plugin": ^8.31.1 - "@typescript-eslint/parser": ^8.31.1 - commitizen: ^4.3.1 - esbuild: ^0.27.2 - eslint: ^8.57.1 - eslint-config-prettier: ^10.1.2 - eslint-import-resolver-typescript: ^4.3.5 - eslint-plugin-import: ^2.31.0 - eslint-plugin-prettier: ^5.2.6 - jest: ^29.7.0 - lerna: ^8.2.1 - lerna-changelog: ^2.2.0 - lint-staged: ^15.5.0 - prettier: ^3.5.3 - rimraf: ^6.1.3 - ts-jest: ^29.2.5 - ts-node: ^10.9.2 - typescript: ^5.9.3 - yorkie: ^2.0.0 - languageName: unknown - linkType: soft - "zod-to-json-schema@npm:^3.25.1": version: 3.25.2 resolution: "zod-to-json-schema@npm:3.25.2"