#
tokens: 14521/50000 18/18 files
lines: off (toggle) GitHub
raw markdown copy
# 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:

[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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)')

```