# Directory Structure
```
├── .gitignore
├── .husky
│ └── pre-commit
├── biome.json
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── config.ts
│ ├── index.ts
│ ├── LoggingTransport.ts
│ ├── schemas.ts
│ ├── tools
│ │ ├── customFields.ts
│ │ ├── folders.ts
│ │ ├── index.ts
│ │ ├── projects.ts
│ │ ├── tags.ts
│ │ └── tcases.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.
```
--------------------------------------------------------------------------------
/src/schemas.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod'
export const projectCodeSchema = z
.string()
.regex(/^[A-Z0-9]{2,5}$/, 'Marker must be 2 to 5 characters in format PROJECT_CODE (e.g., BDI)')
.describe('Project code identifier (e.g., BDI)')
```
--------------------------------------------------------------------------------
/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"]
}
```
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import { registerTools as registerProjectsTools } from './projects.js'
import { registerTools as registerTestCasesTools } from './tcases.js'
import { registerTools as registerTestFoldersTools } from './folders.js'
import { registerTools as registerTestTagsTools } from './tags.js'
import { registerTools as registerCustomFieldsTools } from './customFields.js'
export const registerTools = (server: McpServer) => {
registerProjectsTools(server)
registerTestCasesTools(server)
registerTestFoldersTools(server)
registerTestTagsTools(server)
registerCustomFieldsTools(server)
}
```
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
```typescript
import dotenv from 'dotenv'
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)
}
}
export 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!)
export const QASPHERE_API_KEY = process.env.QASPHERE_API_KEY!
```
--------------------------------------------------------------------------------
/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
}
}
}
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
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 { registerTools } from './tools/index.js'
// 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.',
})
registerTools(server)
// 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)
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "qasphere-mcp",
"version": "0.3.0",
"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 --no-errors-on-unmatched"
}
}
```
--------------------------------------------------------------------------------
/src/tools/customFields.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import axios from 'axios'
import { QASPHERE_API_KEY, QASPHERE_TENANT_URL } from '../config.js'
import type { CustomFieldsResponse } from '../types.js'
import { projectCodeSchema } from '../schemas.js'
export const registerTools = (server: McpServer) => {
server.tool(
'list_custom_fields',
"List all custom fields available for a project. This endpoint is useful when creating or updating test cases that include custom field values. Custom fields allow you to extend test cases with additional metadata specific to your organization's needs.",
{
projectCode: projectCodeSchema,
},
async ({ projectCode }: { projectCode: string }) => {
try {
const response = await axios.get<CustomFieldsResponse>(
`${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/custom-field`,
{
headers: {
Authorization: `ApiKey ${QASPHERE_API_KEY}`,
'Content-Type': 'application/json',
},
}
)
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.customFields),
},
],
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
switch (error.response?.status) {
case 404:
throw new Error(`Project with code '${projectCode}' not found.`)
case 401:
throw new Error('Invalid or missing API key')
case 403:
throw new Error('Insufficient permissions or suspended tenant')
default:
throw new Error(
`Failed to fetch custom fields: ${error.response?.data?.message || error.message}`
)
}
}
throw error
}
}
)
}
```
--------------------------------------------------------------------------------
/src/tools/tags.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import axios from 'axios'
import { QASPHERE_API_KEY, QASPHERE_TENANT_URL } from '../config.js'
import { projectCodeSchema } from '../schemas.js'
export const registerTools = (server: McpServer) => {
server.tool(
'list_test_cases_tags',
'List all tags defined within a specific QA Sphere project.',
{
projectCode: projectCodeSchema,
},
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
}
}
)
}
```
--------------------------------------------------------------------------------
/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/tools/projects.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import axios from 'axios'
import type { Project } from '../types.js'
import { QASPHERE_API_KEY, QASPHERE_TENANT_URL } from '../config.js'
import { projectCodeSchema } from '../schemas.js'
export const registerTools = (server: McpServer) => {
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: projectCodeSchema,
},
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
}
}
)
}
```
--------------------------------------------------------------------------------
/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/tools/folders.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import axios from 'axios'
import { z } from 'zod'
import type {
TestFolderListResponse,
BulkUpsertFoldersRequest,
BulkUpsertFoldersResponse,
} from '../types.js'
import { QASPHERE_API_KEY, QASPHERE_TENANT_URL } from '../config.js'
import { projectCodeSchema } from '../schemas.js'
export const registerTools = (server: McpServer) => {
server.tool(
'list_folders',
'List folders for test cases within a specific QA Sphere project. Allows pagination and sorting.',
{
projectCode: projectCodeSchema,
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(
'upsert_folders',
"Creates or updates multiple folders in a single request using folder path hierarchies. Automatically creates nested folder structures and updates existing folders' comments. Returns an array of folder ID arrays, each representing the full folder path hierarchy as an array of folder IDs.",
{
projectCode: projectCodeSchema,
folders: z
.array(
z.object({
path: z
.array(z.string().min(1).max(255))
.min(1)
.describe('Array of folder names representing the hierarchy'),
comment: z
.string()
.optional()
.describe(
'Additional notes or description for the leaf folder (HTML format). Set null or omit to keep existing comment of an existing folder.'
),
})
)
.min(1)
.describe('Array of folder requests to create or update'),
},
async ({ projectCode, folders }) => {
try {
// Validate folder paths
for (const folder of folders) {
for (const folderName of folder.path) {
if (folderName.includes('/')) {
throw new Error('Folder names cannot contain forward slash (/) characters')
}
if (folderName.trim() === '') {
throw new Error('Folder names cannot be empty strings')
}
}
}
const requestBody: BulkUpsertFoldersRequest = {
folders: folders.map((folder) => ({
path: folder.path,
comment: folder.comment,
})),
}
const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tcase/folder/bulk`
const response = await axios.post<BulkUpsertFoldersResponse>(url, requestBody, {
headers: {
Authorization: `ApiKey ${QASPHERE_API_KEY}`,
'Content-Type': 'application/json',
},
})
const result = response.data
// Basic validation of response
if (!result || !Array.isArray(result.ids)) {
throw new Error('Invalid response: expected an array of folder ID arrays')
}
// Validate that the number of returned ID arrays matches the number of input folders
if (result.ids.length !== folders.length) {
throw new Error(
`Invalid response: expected ${folders.length} folder ID arrays, got ${result.ids.length}`
)
}
// Validate that each ID array has the correct length
for (let i = 0; i < result.ids.length; i++) {
const idArray = result.ids[i]
const expectedLength = folders[i].path.length
if (!Array.isArray(idArray) || idArray.length !== expectedLength) {
throw new Error(
`Invalid response: folder ${i} expected ${expectedLength} IDs, got ${idArray?.length || 0}`
)
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 400) {
throw new Error(
`Invalid request: ${error.response?.data?.message || 'Invalid request body or folder path format'}`
)
}
if (error.response?.status === 401) {
throw new Error('Invalid or missing API key')
}
if (error.response?.status === 403) {
throw new Error('Insufficient permissions or suspended tenant')
}
if (error.response?.status === 404) {
throw new Error(`Project with code '${projectCode}' not found`)
}
if (error.response?.status === 500) {
throw new Error('Internal server error')
}
throw new Error(
`Failed to bulk upsert folders: ${error.response?.data?.message || error.message}`
)
}
throw 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
}
export interface BulkUpsertFolderRequest {
path: string[] // Array of folder names representing the hierarchy
comment?: string // Additional notes or description for the leaf folder (HTML format)
}
export interface BulkUpsertFoldersRequest {
folders: BulkUpsertFolderRequest[] // Array of folder requests
}
export interface BulkUpsertFoldersResponse {
ids: number[][] // Each array represents the full folder path hierarchy as an array of folder IDs
}
// Create Test Case API Types
export interface CreateTestCaseStep {
sharedStepId?: number // For shared steps
description?: string // For standalone steps
expected?: string // For standalone steps
}
export interface CreateTestCaseRequirement {
text: string // Title of the requirement (1-255 characters)
url: string // URL of the requirement (1-255 characters)
}
export interface CreateTestCaseLink {
text: string // Title of the link (1-255 characters)
url: string // URL of the link (1-255 characters)
}
export interface TestCaseCustomFieldValue {
isDefault?: boolean // Whether to set the default value (if true, the value field should be omitted)
value?: string // Custom field value to be set. For text fields: any string value. For dropdown fields: must match one of the option value strings. Omit if 'isDefault' is true.
}
export interface CreateTestCaseParameterValue {
values: { [key: string]: string } // Values for the parameters in the template test case
}
export interface CreateTestCaseRequest {
title: string // Required: Test case title (1-511 characters)
type: 'standalone' | 'template' // Required: Type of test case
folderId: number // Required: ID of the folder where the test case will be placed
priority: 'high' | 'medium' | 'low' // Required: Test case priority
pos?: number // Optional: Position within the folder (0-based index)
comment?: string // Optional: Test case precondition (HTML)
steps?: CreateTestCaseStep[] // Optional: List of test case steps
tags?: string[] // Optional: List of tag titles (max 255 characters each)
requirements?: CreateTestCaseRequirement[] // Optional: Test case requirements
links?: CreateTestCaseLink[] // Optional: Additional links relevant to the test case
customFields?: { [key: string]: TestCaseCustomFieldValue } // Optional: Custom field values
parameterValues?: CreateTestCaseParameterValue[] // Optional: Values to substitute for parameters in template test cases
filledTCaseTitleSuffixParams?: string[] // Optional: Parameters to append to filled test case titles
isDraft?: boolean // Whether to create as draft, default false
}
export interface CreateTestCaseResponse {
id: string // Unique identifier of the created test case
seq: number // Sequence number of the test case in the project
}
// Update Test Case API Types
export interface UpdateTestCaseStep {
sharedStepId?: number // For shared steps
description?: string // For standalone steps
expected?: string // For standalone steps
}
export interface UpdateTestCaseRequirement {
text: string // Title of the requirement (1-255 characters)
url: string // URL of the requirement (1-255 characters)
}
export interface UpdateTestCaseLink {
text: string // Title of the link (1-255 characters)
url: string // URL of the link (1-255 characters)
}
export interface UpdateTestCaseParameterValue {
tcaseId?: string // Should be specified to update existing filled test case
values: { [key: string]: string } // Values for the parameters in the template test case
}
export interface UpdateTestCaseRequest {
title?: string // Optional: Test case title (1-511 characters)
priority?: 'high' | 'medium' | 'low' // Optional: Test case priority
comment?: string // Optional: Test case precondition (HTML)
isDraft?: boolean // Optional: To publish a draft test case
steps?: UpdateTestCaseStep[] // Optional: List of test case steps
tags?: string[] // Optional: List of tag titles (max 255 characters each)
requirements?: UpdateTestCaseRequirement[] // Optional: Test case requirements
links?: UpdateTestCaseLink[] // Optional: Additional links relevant to the test case
customFields?: { [key: string]: TestCaseCustomFieldValue } // Optional: Custom field values
parameterValues?: UpdateTestCaseParameterValue[] // Optional: Values to substitute for parameters in template test cases
}
export interface UpdateTestCaseResponse {
message: string // Success message
}
// Custom Fields API Types
export interface CustomFieldOption {
id: string // Option identifier
value: string // Option display value
}
export interface CustomField {
id: string // Unique custom field identifier
type: 'text' | 'dropdown' // Field type
systemName: string // System identifier for the field (used in API requests)
name: string // Display name of the field
required: boolean // Whether the field is required for test cases
enabled: boolean // Whether the field is currently enabled
options?: CustomFieldOption[] // Available options (only for dropdown fields)
defaultValue?: string // Default value for the field
pos: number // Display position/order
allowAllProjects: boolean // Whether the field is available to all projects
allowedProjectIds?: string[] // List of project IDs if not available to all projects
createdAt: string // ISO 8601 timestamp when the field was created
updatedAt: string // ISO 8601 timestamp when the field was last updated
}
export interface CustomFieldsResponse {
customFields: CustomField[] // Array of custom fields
}
```
--------------------------------------------------------------------------------
/src/tools/tcases.ts:
--------------------------------------------------------------------------------
```typescript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'
import axios from 'axios'
import { z } from 'zod'
import type {
TestCase,
TestCasesListResponse,
CreateTestCaseRequest,
CreateTestCaseResponse,
UpdateTestCaseRequest,
UpdateTestCaseResponse,
} from '../types.js'
import { QASPHERE_API_KEY, QASPHERE_TENANT_URL } from '../config.js'
import { JSONStringify } from '../utils.js'
import { projectCodeSchema } from '../schemas.js'
export const registerTools = (server: McpServer) => {
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: testCaseMarkerSchema,
},
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) {
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(
'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: projectCodeSchema,
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(
'create_test_case',
'Create a new test case in QA Sphere. Supports both standalone and template test cases with various options like steps, tags, requirements, and parameter values for templates.',
{
projectId: projectCodeSchema,
title: z
.string()
.min(1, 'Title must be at least 1 character')
.max(511, 'Title must be at most 511 characters')
.describe('Test case title'),
type: z
.enum(['standalone', 'template'])
.describe('Type of test case (standalone or template)'),
folderId: z
.number()
.int()
.positive('Folder ID must be a positive integer')
.describe(
'ID of the folder where the test case will be placed. Use bulk_upsert_folders tool to create new folders or get existing folders.'
),
priority: z.enum(['high', 'medium', 'low']).describe('Test case priority'),
pos: z
.number()
.int()
.min(0, 'Position must be non-negative')
.optional()
.describe('Position within the folder (0-based index)'),
comment: z.string().optional().describe('Test case precondition (HTML format)'),
steps: z
.array(
z.object({
sharedStepId: z
.number()
.int()
.positive()
.optional()
.describe('Unique identifier of the shared step'),
description: z.string().optional().describe('Details of steps (HTML format)'),
expected: z.string().optional().describe('Expected result from the step (HTML format)'),
})
)
.optional()
.describe('List of test case steps'),
tags: z
.array(z.string().max(255, 'Tag title must be at most 255 characters'))
.optional()
.describe('List of tag titles'),
requirements: z
.array(
z.object({
text: z
.string()
.min(1, 'Requirement text must be at least 1 character')
.max(255, 'Requirement text must be at most 255 characters')
.describe('Title of the requirement'),
url: z
.string()
.min(1, 'Requirement URL must be at least 1 character')
.max(255, 'Requirement URL must be at most 255 characters')
.url('Requirement URL must be a valid URL')
.describe('URL of the requirement'),
})
)
.optional()
.describe('Test case requirements'),
links: z
.array(
z.object({
text: z
.string()
.min(1, 'Link text must be at least 1 character')
.max(255, 'Link text must be at most 255 characters')
.describe('Title of the link'),
url: z
.string()
.min(1, 'Link URL must be at least 1 character')
.max(255, 'Link URL must be at most 255 characters')
.url('Link URL must be a valid URL')
.describe('URL of the link'),
})
)
.optional()
.describe('Additional links relevant to the test case'),
customFields: tcaseCustomFieldParamSchema,
parameterValues: z
.array(
z.object({
values: z
.record(z.string())
.describe('Values for the parameters in the template test case'),
})
)
.optional()
.describe('Values to substitute for parameters in template test cases'),
filledTCaseTitleSuffixParams: z
.array(z.string())
.optional()
.describe('Parameters to append to filled test case titles'),
isDraft: z.boolean().optional().default(false).describe('Whether to create as draft'),
},
async ({ projectId, ...tcaseParams }) => {
try {
const requestData: CreateTestCaseRequest = {
...tcaseParams,
}
const response = await axios.post<CreateTestCaseResponse>(
`${QASPHERE_TENANT_URL}/api/public/v0/project/${projectId}/tcase`,
requestData,
{
headers: {
Authorization: `ApiKey ${QASPHERE_API_KEY}`,
'Content-Type': 'application/json',
},
}
)
const result = response.data
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const message = error.response?.data?.message || error.message
if (status === 400) {
throw new Error(`Invalid request data: ${message}`)
}
if (status === 401) {
throw new Error('Invalid or missing API key')
}
if (status === 403) {
throw new Error('Insufficient permissions or suspended tenant')
}
if (status === 404) {
throw new Error(`Project or folder not found: ${message}`)
}
if (status === 409) {
throw new Error(`Position conflict or duplicate requirement: ${message}`)
}
if (status === 500) {
throw new Error('Internal server error while creating test case')
}
throw new Error(`Failed to create test case: ${message}`)
}
throw error
}
}
)
server.tool(
'update_test_case',
'Update an existing test case in QA Sphere. Only users with role User or higher are allowed to update test cases. Optional fields can be omitted to keep the current value.',
{
projectId: projectCodeSchema,
tcaseOrLegacyId: z
.string()
.describe('Test case identifier (can be one of test case UUID, sequence or legacy ID)'),
title: z
.string()
.min(1, 'Title must be at least 1 character')
.max(511, 'Title must be at most 511 characters')
.optional()
.describe('Test case title'),
priority: z.enum(['high', 'medium', 'low']).optional().describe('Test case priority'),
comment: z.string().optional().describe('Test case precondition (HTML format)'),
isDraft: z
.boolean()
.optional()
.describe(
'To publish a draft test case. A published test case cannot be converted to draft'
),
steps: z
.array(
z.object({
sharedStepId: z
.number()
.int()
.positive()
.optional()
.describe('Unique identifier of the shared step'),
description: z.string().optional().describe('Details of steps (HTML format)'),
expected: z.string().optional().describe('Expected result from the step (HTML format)'),
})
)
.optional()
.describe('List of test case steps'),
tags: z
.array(z.string().max(255, 'Tag title must be at most 255 characters'))
.optional()
.describe('List of tag titles'),
requirements: z
.array(
z.object({
text: z
.string()
.min(1, 'Requirement text must be at least 1 character')
.max(255, 'Requirement text must be at most 255 characters')
.describe('Title of the requirement'),
url: z
.string()
.min(1, 'Requirement URL must be at least 1 character')
.max(255, 'Requirement URL must be at most 255 characters')
.url('Requirement URL must be a valid URL')
.describe('URL of the requirement'),
})
)
.optional()
.describe('Test case requirements'),
links: z
.array(
z.object({
text: z
.string()
.min(1, 'Link text must be at least 1 character')
.max(255, 'Link text must be at most 255 characters')
.describe('Title of the link'),
url: z
.string()
.min(1, 'Link URL must be at least 1 character')
.max(255, 'Link URL must be at most 255 characters')
.url('Link URL must be a valid URL')
.describe('URL of the link'),
})
)
.optional()
.describe('Additional links relevant to the test case'),
customFields: tcaseCustomFieldParamSchema,
parameterValues: z
.array(
z.object({
tcaseId: z
.string()
.optional()
.describe('Should be specified to update existing filled test case'),
values: z
.record(z.string())
.describe('Values for the parameters in the template test case'),
})
)
.optional()
.describe('Values to substitute for parameters in template test cases'),
},
async ({ projectId, tcaseOrLegacyId, ...updateParams }) => {
try {
const requestData: UpdateTestCaseRequest = {
...updateParams,
}
const response = await axios.patch<UpdateTestCaseResponse>(
`${QASPHERE_TENANT_URL}/api/public/v0/project/${projectId}/tcase/${tcaseOrLegacyId}`,
requestData,
{
headers: {
Authorization: `ApiKey ${QASPHERE_API_KEY}`,
'Content-Type': 'application/json',
},
}
)
const result = response.data
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const message = error.response?.data?.message || error.message
if (status === 400) {
throw new Error(
`Invalid request data or converting a published test case to draft: ${message}`
)
}
if (status === 401) {
throw new Error('Invalid or missing API key')
}
if (status === 403) {
throw new Error('Insufficient permissions or suspended tenant')
}
if (status === 404) {
throw new Error(`Project or test case not found: ${message}`)
}
if (status === 500) {
throw new Error('Internal server error while updating test case')
}
throw new Error(`Failed to update test case: ${message}`)
}
throw error
}
}
)
}
const tcaseCustomFieldParamSchema = z
.record(
z.string(),
z
.object({
value: z
.string()
.optional()
.describe(
"The actual value for the field. For text fields: any string value. For dropdown fields: must match one of the option value strings from the field's options array. Omit if 'isDefault' is true."
),
isDefault: z
.boolean()
.optional()
.describe(
"Boolean indicating whether to use the field's default value (if true, the value field should be omitted)"
),
})
.refine((data) => data.value !== undefined || data.isDefault !== undefined, {
message: "For each custom field provided, either 'value' or 'isDefault' must be specified.",
})
)
.optional()
.describe(
'Custom field values. Use the systemName property from custom fields as the key. Only enabled fields should be used. Use list_custom_fields tool to get the custom fields.'
)
const testCaseMarkerSchema = z
.string()
.regex(
/^[A-Z0-9]{2,5}-\d+$/,
'Marker must be in format PROJECT_CODE-SEQUENCE (e.g., BDI-123). Project code must be 2 to 5 characters in format PROJECT_CODE (e.g., BDI). Sequence must be a number.'
)
.describe('Test case marker in format PROJECT_CODE-SEQUENCE (e.g., BDI-123)')
```