# Directory Structure ``` ├── .gitignore ├── .husky │ └── pre-commit ├── biome.json ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── index.ts │ ├── LoggingTransport.ts │ ├── types.ts │ ├── utils.test.ts │ └── utils.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Compiled output dist/ # Environment variables .env .env.local .env.*.local # IDE files .vscode/ .idea/ *.sublime-workspace *.sublime-project # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Operating System .DS_Store Thumbs.db # Test coverage coverage/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache mcp-log.txt ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # QA Sphere MCP Server A [Model Context Protocol](https://github.com/modelcontextprotocol) server for the [QA Sphere](https://qasphere.com/) test management system. This integration enables Large Language Models (LLMs) to interact directly with QA Sphere test cases, allowing you to discover, summarize, and chat about test cases. In AI-powered IDEs that support MCP, you can reference specific QA Sphere test cases within your development workflow. ## Prerequisites - Node.js (recent LTS versions) - QA Sphere account with API access - API key from QA Sphere (Settings ⚙️ → API Keys → Add API Key) - Your company's QA Sphere URL (e.g., `example.eu2.qasphere.com`) ## Setup Instructions This server is compatible with any MCP client. Configuration instructions for popular clients are provided below. ### Claude Desktop 1. Navigate to `Claude` → `Settings` → `Developer` → `Edit Config` 2. Open `claude_desktop_config.json` 3. Add the QA Sphere configuration to the `mcpServers` dictionary ### Cursor #### Option 1: Manual Configuration 1. Go to `Settings...` → `Cursor settings` → `Add new global MCP server` 2. Add the QA Sphere configuration #### Option 2: Quick Install Click the button below to automatically install and configure the QA Sphere MCP server: [](https://cursor.com/install-mcp?name=qasphere&config=eyJjb21tYW5kIjoibnB4IC15IHFhc3BoZXJlLW1jcCIsImVudiI6eyJRQVNQSEVSRV9URU5BTlRfVVJMIjoieW91ci1jb21wYW55LnJlZ2lvbi5xYXNwaGVyZS5jb20iLCJRQVNQSEVSRV9BUElfS0VZIjoieW91ci1hcGkta2V5In19) ### 5ire 1. Open 'Tools' and press 'New' 2. Complete the form with: - Tool key: `qasphere` - Command: `npx -y qasphere-mcp` - Environment variables (see below) ### Configuration Template For any MCP client, use the following configuration format: ```json { "mcpServers": { "qasphere": { "command": "npx", "args": ["-y", "qasphere-mcp"], "env": { "QASPHERE_TENANT_URL": "your-company.region.qasphere.com", "QASPHERE_API_KEY": "your-api-key" } } } } ``` Replace the placeholder values with your actual QA Sphere URL and API key. ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Support If you encounter any issues or need assistance, please file an issue on the GitHub repository. ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2020", "module": "ES2020", "moduleResolution": "node", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": true, "ignore": [] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "organizeImports": { "enabled": true }, "linter": { "enabled": true, "rules": { "recommended": true, "style": { "noNonNullAssertion": "off" }, "suspicious": { "noExplicitAny": "off" }, "correctness": { "useExhaustiveDependencies": "error" }, "complexity": { "noForEach": "off" }, "a11y": { "useKeyWithClickEvents": "error", "useMediaCaption": "error" }, "nursery": { "useImportRestrictions": "off" } } }, "javascript": { "formatter": { "quoteStyle": "single", "jsxQuoteStyle": "single", "trailingCommas": "es5", "semicolons": "asNeeded", "arrowParentheses": "always", "bracketSpacing": true } } } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "qasphere-mcp", "version": "0.2.1", "description": "MCP server for QA Sphere integration", "type": "module", "main": "dist/index.js", "bin": { "qasphere-mcp": "./dist/index.js" }, "files": ["dist", "README.md", "LICENSE"], "repository": { "type": "git", "url": "git+https://github.com/Hypersequent/qasphere-mcp.git" }, "keywords": ["mcp", "qasphere", "tms"], "author": "Hypersequent", "license": "MIT", "scripts": { "build": "tsc && chmod +x dist/index.js", "dev": "tsx src/index.ts", "lint": "biome lint --write .", "format": "biome format --write .", "inspector": "npx @modelcontextprotocol/inspector tsx src/index.ts", "test": "vitest run", "prepare": "husky" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "axios": "^1.6.7", "dotenv": "^16.4.5", "zod": "^3.22.4" }, "devDependencies": { "@biomejs/biome": "1.9.4", "@types/node": "^22.13.16", "husky": "^9.1.7", "lint-staged": "^15.5.1", "tsx": "^4.19.3", "typescript": "^5.8.2", "vitest": "^3.1.1" }, "lint-staged": { "*.{ts,js}": ["biome lint --write", "biome format --write"], "*.json": "biome format --write" } } ``` -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- ```typescript // Type definition for the renameKeys map export type RenameMap = { [key: string]: string | RenameMap } // Helper function to recursively rename keys and process nested structures const renameKeyInObject = (obj: any, renameKeys: RenameMap): any => { // Base case: return non-objects/arrays as is if (typeof obj !== 'object' || obj === null) { return obj } // Handle arrays: recursively process each element with the *original* full rename map if (Array.isArray(obj)) { // Important: Pass the original renameKeys map down, not a potentially nested part of it. return obj.map((item) => renameKeyInObject(item, renameKeys)) } // Handle objects const newObj: Record<string, any> = {} for (const key in obj) { // Ensure it's an own property if (Object.prototype.hasOwnProperty.call(obj, key)) { const currentValue = obj[key] const renameTarget = renameKeys[key] if (typeof renameTarget === 'string') { // Simple rename: Use the new key, recursively process the value with the *original* full rename map newObj[renameTarget] = currentValue } else if ( typeof renameTarget === 'object' && renameTarget !== null && !Array.isArray(renameTarget) ) { // Nested rename rule provided: Keep the original key, recursively process the value with the specific *nested* rules newObj[key] = renameKeyInObject(currentValue, renameTarget as RenameMap) } else { newObj[key] = currentValue } } } return newObj } /** * Creates a JSON string from an object after renaming keys according to a map. * Handles nested objects and arrays, applying rules deeply. * * @param obj The input object or array. * @param renameKeys A map defining key renames. String values rename the key directly. * Object values indicate nested rules for the value of that key. * Example: { oldKey: 'newKey', nestedKey: { oldInnerKey: 'newInnerKey' } } * @returns A JSON string representation of the transformed object. */ export function JSONStringify(obj: any, renameKeys: RenameMap = {}): string { const transformedObj = renameKeyInObject(obj, renameKeys) // Use JSON.stringify with indentation for better readability if needed // return JSON.stringify(transformedObj, null, 2); return JSON.stringify(transformedObj) } ``` -------------------------------------------------------------------------------- /src/LoggingTransport.ts: -------------------------------------------------------------------------------- ```typescript import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import type { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' import * as fs from 'node:fs' import * as path from 'node:path' /** * A wrapper transport that logs all MCP communication to a file */ export class LoggingTransport implements Transport { private wrapped: StdioServerTransport private logStream: fs.WriteStream private logFile: string sessionId?: string constructor(wrapped: StdioServerTransport, logFile: string) { // Store wrapped transport this.wrapped = wrapped // Set up logging this.logFile = logFile // Create log directory if it doesn't exist const logDir = path.dirname(this.logFile) if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }) } // Create log stream this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' }) // Log initial connection information this.log({ type: 'connection_info', timestamp: new Date().toISOString(), message: 'LoggingTransport initialized', }) // Set up forwarding of events this.wrapped.onmessage = (message: JSONRPCMessage) => { this.log({ type: 'received', timestamp: new Date().toISOString(), message, }) if (this.onmessage) this.onmessage(message) } this.wrapped.onerror = (error: Error) => { this.log({ type: 'error', timestamp: new Date().toISOString(), error: error.message, stack: error.stack, }) if (this.onerror) this.onerror(error) } this.wrapped.onclose = () => { this.log({ type: 'close', timestamp: new Date().toISOString(), message: 'Connection closed', }) if (this.onclose) this.onclose() // Close the log stream when the connection closes this.logStream.end() } } // Implementation of Transport interface onclose?: () => void onerror?: (error: Error) => void onmessage?: (message: JSONRPCMessage) => void async start(): Promise<void> { return this.wrapped.start() } async close(): Promise<void> { return this.wrapped.close() } async send(message: JSONRPCMessage): Promise<void> { this.log({ type: 'sent', timestamp: new Date().toISOString(), message, }) return this.wrapped.send(message) } private log(data: any): void { try { this.logStream.write(`${JSON.stringify(data)}\n`) } catch (error) { console.error('Failed to write to log file:', error) } } } ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript export interface TestStep { description: string expected: string } export interface TestTag { id: number title: string } export interface TestFile { id: string fileName: string mimeType: string size: number url: string } export interface TestRequirement { id: string text: string url: string } export interface TestLink { text: string url: string } export interface TestCase { id: string // Unique identifier of the test case legacyId: string // Legacy identifier of the test case. Empty string if the test case has no legacy ID version: number // Version of the test case. Updates to test (except folder/pos) creates a new version title: string // Title of the test case seq: number // Sequence number of the test case. Test cases in a project are assigned incremental sequence numbers folderId: number // Identifier of the folder where the test case is placed pos: number // Ordered position (0 based) of the test case in its folder priority: 'high' | 'medium' | 'low' // Priority of the test case comment: string // Test description/precondition steps: TestStep[] // List of test case steps tags: TestTag[] // List of test case tags files: TestFile[] // List of files attached to the test case requirements: TestRequirement[] // Test case requirement (currently only single requirement is supported on UI) links: TestLink[] // Additional links relevant to the test case authorId: number // Unique identifier of the user who added the test case isDraft: boolean // Whether the test case is still in draft state isLatestVersion: boolean // Whether this is the latest version of the test case createdAt: string // Test case creation time (ISO 8601 format) updatedAt: string // Test case updation time (ISO 8601 format) } export interface TestCasesListResponse { total: number // Total number of filtered test cases page: number // Current page number limit: number // Number of test cases per page data: TestCase[] // List of test case objects } export interface ProjectLink { url: string text: string } export interface Project { id: string code: string title: string description: string overviewTitle: string overviewDescription: string links: ProjectLink[] createdAt: string updatedAt: string archivedAt: string | null } export interface TestFolder { id: number // Unique identifier for the folder title: string // Name of the folder comment: string // Additional notes or description pos: number // Position of the folder among its siblings parentId: number // ID of the parent folder (0 for root folders) projectId: string // ID of the project the folder belongs to } export interface TestFolderListResponse { total: number // Total number of items available page: number // Current page number limit: number // Number of items per page data: TestFolder[] // Array of folder objects } ``` -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest' import { JSONStringify, type RenameMap } from './utils' describe('JSONStringify', () => { const baseObject = { a: 1, b: 2, c: { a: 11, b: 22, d: { a: 111 } }, d: [ { a: 50, x: 100 }, { b: 60, a: 70 }, ], e: null, f: [1, 2, 3], g: { a: 'keep_g' }, } it('should return JSON string of object without changes when renameKeys is empty', () => { const result = JSONStringify(baseObject, {}) expect(JSON.parse(result)).toEqual(baseObject) }) it('should handle the first example: {a:z}', () => { const o = { a: 1, b: 2, c: { a: 11, b: 22 } } const renameMap: RenameMap = { a: 'z' } const expected = { z: 1, b: 2, c: { a: 11, b: 22 } } const result = JSONStringify(o, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should handle the second example: {c:{a:z}}', () => { const o = { a: 1, b: 2, c: { a: 11, b: 22 } } const renameMap: RenameMap = { c: { a: 'z' } } const expected = { a: 1, b: 2, c: { z: 11, b: 22 } } const result = JSONStringify(o, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should rename only specified top-level keys when using string values', () => { const renameMap: RenameMap = { a: 'z', b: 'y' } const expected = { z: 1, // renamed y: 2, // renamed c: { a: 11, b: 22, d: { a: 111 } }, // nested a/b untouched d: [ { a: 50, x: 100 }, { b: 60, a: 70 }, ], // nested a/b untouched e: null, f: [1, 2, 3], g: { a: 'keep_g' }, // nested a untouched } const result = JSONStringify(baseObject, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should rename nested keys using nested rename map objects', () => { const renameMap: RenameMap = { c: { a: 'z', d: { a: 'k' } }, g: { a: 'g_new' }, } const expected = { a: 1, b: 2, c: { z: 11, b: 22, d: { k: 111 } }, // c.a renamed to z, c.d.a renamed to k d: [ { a: 50, x: 100 }, { b: 60, a: 70 }, ], // d array untouched e: null, f: [1, 2, 3], g: { g_new: 'keep_g' }, // g.a renamed } const result = JSONStringify(baseObject, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should rename keys deeply within arrays if a matching nested rule exists', () => { // To rename 'a' inside the objects within the array 'd', // the rename map needs to target 'a' at the level where it occurs. const renameMap: RenameMap = { d: { a: 'z' } } // This applies universally const expected = { a: 1, b: 2, c: { a: 11, b: 22, d: { a: 111 } }, d: [ { z: 50, x: 100 }, { b: 60, z: 70 }, ], e: null, f: [1, 2, 3], g: { a: 'keep_g' }, } const result = JSONStringify(baseObject, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should handle mixed simple and nested renaming rules', () => { const renameMap: RenameMap = { a: 'z', c: { b: 'y' } } const expected = { z: 1, // Renamed by top-level rule {a: 'z'} b: 2, c: { a: 11, y: 22, d: { a: 111 } }, // c.b renamed to y by nested rule, c.a and c.d.a untouched by *this* rule d: [ { a: 50, x: 100 }, { b: 60, a: 70 }, ], e: null, f: [1, 2, 3], g: { a: 'keep_g' }, } const result = JSONStringify(baseObject, renameMap) expect(JSON.parse(result)).toEqual(expected) }) it('should handle empty objects', () => { const renameMap: RenameMap = { a: 'z' } expect(JSON.parse(JSONStringify({}, renameMap))).toEqual({}) }) it('should handle objects with null values', () => { const obj = { a: 1, b: null } const renameMap: RenameMap = { a: 'z' } const expected = { z: 1, b: null } expect(JSON.parse(JSONStringify(obj, renameMap))).toEqual(expected) }) it('should handle arrays directly if passed', () => { const arr = [{ a: 1 }, { a: 2 }] const renameMap: RenameMap = { a: 'z' } const expected = [{ z: 1 }, { z: 2 }] expect(JSON.parse(JSONStringify(arr, renameMap))).toEqual(expected) }) it('should handle non-string/object values in renameKeys gracefully (treat as no-op for that key)', () => { // The type system prevents this, but testing javascript flexibility const renameMap: any = { a: true, b: 'y' } const obj = { a: 1, b: 2, c: 3 } const expected = { a: 1, y: 2, c: 3 } // 'a' is not renamed (invalid rule type), 'b' is const result = JSONStringify(obj, renameMap) expect(JSON.parse(result)).toEqual(expected) }) }) ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import type { Project, TestCase, TestCasesListResponse, TestFolderListResponse } from './types.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { LoggingTransport } from './LoggingTransport.js' import { JSONStringify } from './utils.js' import dotenv from 'dotenv' import axios from 'axios' import { z } from 'zod' dotenv.config() // Validate required environment variables const requiredEnvVars = ['QASPHERE_TENANT_URL', 'QASPHERE_API_KEY'] for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { console.error(`Error: Missing required environment variable: ${envVar}`) process.exit(1) } } const QASPHERE_TENANT_URL = ((url: string) => { let tenantUrl = url if ( !tenantUrl.toLowerCase().startsWith('http://') && !tenantUrl.toLowerCase().startsWith('https://') ) { tenantUrl = `https://${tenantUrl}` } if (tenantUrl.endsWith('/')) { tenantUrl = tenantUrl.slice(0, -1) } return tenantUrl })(process.env.QASPHERE_TENANT_URL!) const QASPHERE_API_KEY = process.env.QASPHERE_API_KEY! // Create MCP server const server = new McpServer({ name: 'qasphere-mcp', version: process.env.npm_package_version || '0.0.0', description: 'QA Sphere MCP server for fetching test cases and projects.', }) // Add the get_test_case tool server.tool( 'get_test_case', `Get a test case from QA Sphere using a marker in the format PROJECT_CODE-SEQUENCE (e.g., BDI-123). You can use URLs like: ${QASPHERE_TENANT_URL}/project/%PROJECT_CODE%/tcase/%SEQUENCE%?any Extract %PROJECT_CODE% and %SEQUENCE% from the URL and use them as the marker.`, { marker: z .string() .regex(/^[A-Z0-9]+-\d+$/, 'Marker must be in format PROJECT_CODE-SEQUENCE (e.g., BDI-123)') .describe('Test case marker in format PROJECT_CODE-SEQUENCE (e.g., BDI-123)'), }, async ({ marker }: { marker: string }) => { try { const [projectId, sequence] = marker.split('-') const response = await axios.get<TestCase>( `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectId}/tcase/${sequence}`, { headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, } ) const testCase = response.data // Sanity check for required fields if (!testCase.id || !testCase.title || !testCase.version === undefined) { throw new Error('Invalid test case data: missing required fields (id, title, or version)') } return { content: [ { type: 'text', text: JSONStringify(testCase, { comment: 'precondition', steps: { description: 'action', expected: 'expected_result' }, }), }, ], } } catch (error: unknown) { if (axios.isAxiosError(error)) { throw new Error( `Failed to fetch test case: ${error.response?.data?.message || error.message}` ) } throw error } } ) server.tool( 'get_project', `Get a project information from QA Sphere using a project code (e.g., BDI). You can extract PROJECT_CODE from URLs ${QASPHERE_TENANT_URL}/project/%PROJECT_CODE%/...`, { projectCode: z .string() .regex(/^[A-Z0-9]+$/, 'Marker must be in format PROJECT_CODE (e.g., BDI)') .describe('Project code identifier (e.g., BDI)'), }, async ({ projectCode }: { projectCode: string }) => { try { const response = await axios.get<Project>( `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}`, { headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, } ) const projectData = response.data if (!projectData.id || !projectData.title) { throw new Error('Invalid project data: missing required fields (id or title)') } return { content: [{ type: 'text', text: JSON.stringify(projectData) }], } } catch (error: unknown) { if (axios.isAxiosError(error)) { if (error.response?.status === 404) { throw new Error(`Project with code '${projectCode}' not found.`) } throw new Error( `Failed to fetch project: ${error.response?.data?.message || error.message}` ) } throw error } } ) server.tool( 'list_projects', 'Get a list of all projects from current QA Sphere TMS account (qasphere.com)', {}, async () => { try { const response = await axios.get(`${QASPHERE_TENANT_URL}/api/public/v0/project`, { headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, }) const projectsData = response.data if (!Array.isArray(projectsData.projects)) { throw new Error('Invalid response: expected an array of projects') } // if array is non-empty check if object has id and title fields if (projectsData.projects.length > 0) { const firstProject = projectsData.projects[0] if (!firstProject.id || !firstProject.title) { throw new Error('Invalid project data: missing required fields (id or title)') } } return { content: [{ type: 'text', text: JSON.stringify(projectsData) }], } } catch (error: unknown) { if (axios.isAxiosError(error)) { throw new Error( `Failed to fetch projects: ${error.response?.data?.message || error.message}` ) } throw error } } ) server.tool( 'list_test_cases', 'List test cases from a project in QA Sphere. Supports pagination and various filtering options. Usually it makes sense to call get_project tool first to get the project context.', { projectCode: z .string() .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)') .describe('Project code identifier (e.g., BDI)'), page: z.number().optional().describe('Page number for pagination'), limit: z.number().optional().default(20).describe('Number of items per page'), sortField: z .enum([ 'id', 'seq', 'folder_id', 'author_id', 'pos', 'title', 'priority', 'created_at', 'updated_at', 'legacy_id', ]) .optional() .describe('Field to sort results by'), sortOrder: z .enum(['asc', 'desc']) .optional() .describe('Sort direction (ascending or descending)'), search: z.string().optional().describe('Search term to filter test cases'), include: z .array(z.enum(['steps', 'tags', 'project', 'folder', 'path'])) .optional() .describe('Related data to include in the response'), folders: z.array(z.number()).optional().describe('Filter by folder IDs'), tags: z.array(z.number()).optional().describe('Filter by tag IDs'), priorities: z .array(z.enum(['high', 'medium', 'low'])) .optional() .describe('Filter by priority levels'), draft: z.boolean().optional().describe('Filter draft vs published test cases'), }, async ({ projectCode, page, limit = 20, sortField, sortOrder, search, include, folders, tags, priorities, draft, }) => { try { // Build query parameters const params = new URLSearchParams() if (page !== undefined) params.append('page', page.toString()) if (limit !== undefined) params.append('limit', limit.toString()) if (sortField) params.append('sortField', sortField) if (sortOrder) params.append('sortOrder', sortOrder) if (search) params.append('search', search) // Add array parameters if (include) include.forEach((item) => params.append('include', item)) if (folders) folders.forEach((item) => params.append('folders', item.toString())) if (tags) tags.forEach((item) => params.append('tags', item.toString())) if (priorities) priorities.forEach((item) => params.append('priorities', item)) if (draft !== undefined) params.append('draft', draft.toString()) const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tcase` const response = await axios.get<TestCasesListResponse>(url, { params, headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, }) const testCasesList = response.data // Basic validation of response if (!testCasesList || !Array.isArray(testCasesList.data)) { throw new Error('Invalid response: expected a list of test cases') } // check for other fields from TestCasesListResponse if ( testCasesList.total === undefined || testCasesList.page === undefined || testCasesList.limit === undefined ) { throw new Error('Invalid response: missing required fields (total, page, or limit)') } // if array is non-empty check if object has id and title fields if (testCasesList.data.length > 0) { const firstTestCase = testCasesList.data[0] if (!firstTestCase.id || !firstTestCase.title) { throw new Error('Invalid test case data: missing required fields (id or title)') } } return { content: [ { type: 'text', text: JSONStringify(testCasesList, { data: { comment: 'precondition', steps: { description: 'action', expected: 'expected_result', }, }, }), }, ], } } catch (error: unknown) { if (axios.isAxiosError(error)) { if (error.response?.status === 404) { throw new Error(`Project with code '${projectCode}' not found.`) } throw new Error( `Failed to fetch test cases: ${error.response?.data?.message || error.message}` ) } throw error } } ) server.tool( 'list_test_cases_folders', 'List folders for test cases within a specific QA Sphere project. Allows pagination and sorting.', { projectCode: z .string() .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)') .describe('Project code identifier (e.g., BDI)'), page: z.number().optional().describe('Page number for pagination'), limit: z.number().optional().default(100).describe('Number of items per page'), sortField: z .enum(['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at']) .optional() .describe('Field to sort results by'), sortOrder: z .enum(['asc', 'desc']) .optional() .describe('Sort direction (ascending or descending)'), }, async ({ projectCode, page, limit = 100, sortField, sortOrder }) => { try { // Build query parameters const params = new URLSearchParams() if (page !== undefined) params.append('page', page.toString()) if (limit !== undefined) params.append('limit', limit.toString()) if (sortField) params.append('sortField', sortField) if (sortOrder) params.append('sortOrder', sortOrder) const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tcase/folders` const response = await axios.get<TestFolderListResponse>(url, { params, headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, }) const folderList = response.data // Basic validation of response if (!folderList || !Array.isArray(folderList.data)) { throw new Error('Invalid response: expected a list of folders') } // check for other fields from TestFolderListResponse if ( folderList.total === undefined || folderList.page === undefined || folderList.limit === undefined ) { throw new Error('Invalid response: missing required fields (total, page, or limit)') } // if array is non-empty check if object has id and title fields if (folderList.data.length > 0) { const firstFolder = folderList.data[0] if (firstFolder.id === undefined || !firstFolder.title) { throw new Error('Invalid folder data: missing required fields (id or title)') } } return { content: [ { type: 'text', text: JSON.stringify(folderList), // Use standard stringify, no special mapping needed for folders }, ], } } catch (error: unknown) { if (axios.isAxiosError(error)) { if (error.response?.status === 404) { throw new Error(`Project with code '${projectCode}' not found.`) } throw new Error( `Failed to fetch test case folders: ${error.response?.data?.message || error.message}` ) } throw error } } ) server.tool( 'list_test_cases_tags', 'List all tags defined within a specific QA Sphere project.', { projectCode: z .string() .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)') .describe('Project code identifier (e.g., BDI)'), }, async ({ projectCode }: { projectCode: string }) => { try { const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tag` const response = await axios.get<{ tags: Array<{ id: number; title: string }> }>(url, { headers: { Authorization: `ApiKey ${QASPHERE_API_KEY}`, 'Content-Type': 'application/json', }, }) const tagsData = response.data // Basic validation of response if (!tagsData || !Array.isArray(tagsData.tags)) { throw new Error('Invalid response: expected an object with a "tags" array') } // if array is non-empty check if object has id and title fields if (tagsData.tags.length > 0) { const firstTag = tagsData.tags[0] if (firstTag.id === undefined || !firstTag.title) { throw new Error('Invalid tag data: missing required fields (id or title)') } } return { content: [ { type: 'text', text: JSON.stringify(tagsData), // Use standard stringify }, ], } } catch (error: unknown) { if (axios.isAxiosError(error)) { if (error.response?.status === 404) { throw new Error(`Project with identifier '${projectCode}' not found.`) } throw new Error( `Failed to fetch project tags: ${error.response?.data?.message || error.message}` ) } throw error } } ) // Start receiving messages on stdin and sending messages on stdout async function startServer() { // Create base transport const baseTransport = new StdioServerTransport() // Wrap with logging transport if MCP_LOG_TO_FILE is set let transport: Transport = baseTransport if (process.env.MCP_LOG_TO_FILE) { const logFilePath = process.env.MCP_LOG_TO_FILE console.error(`MCP: Logging to file: ${logFilePath}`) transport = new LoggingTransport(baseTransport, logFilePath) } await server.connect(transport) console.error('QA Sphere MCP server started') } startServer().catch(console.error) ```