#
tokens: 8945/50000 10/10 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
│   ├── index.ts
│   ├── LoggingTransport.ts
│   ├── types.ts
│   ├── utils.test.ts
│   └── utils.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Dependencies
node_modules/

# Compiled output
dist/

# Environment variables
.env
.env.local
.env.*.local

# IDE files
.vscode/
.idea/
*.sublime-workspace
*.sublime-project

# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Operating System
.DS_Store
Thumbs.db

# Test coverage
coverage/

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache 

mcp-log.txt

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# QA Sphere MCP Server

A [Model Context Protocol](https://github.com/modelcontextprotocol) server for the [QA Sphere](https://qasphere.com/) test management system.

This integration enables Large Language Models (LLMs) to interact directly with QA Sphere test cases, allowing you to discover, summarize, and chat about test cases. In AI-powered IDEs that support MCP, you can reference specific QA Sphere test cases within your development workflow.

## Prerequisites

- Node.js (recent LTS versions)
- QA Sphere account with API access
- API key from QA Sphere (Settings ⚙️ → API Keys → Add API Key)
- Your company's QA Sphere URL (e.g., `example.eu2.qasphere.com`)

## Setup Instructions

This server is compatible with any MCP client. Configuration instructions for popular clients are provided below.

### Claude Desktop

1. Navigate to `Claude` → `Settings` → `Developer` → `Edit Config`
2. Open `claude_desktop_config.json`
3. Add the QA Sphere configuration to the `mcpServers` dictionary

### Cursor

#### Option 1: Manual Configuration

1. Go to `Settings...` → `Cursor settings` → `Add new global MCP server`
2. Add the QA Sphere configuration

#### Option 2: Quick Install

Click the button below to automatically install and configure the QA Sphere MCP server:

[![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.

```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "moduleResolution": "node",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

```

--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------

```json
{
  "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
  "vcs": {
    "enabled": false,
    "clientKind": "git",
    "useIgnoreFile": false
  },
  "files": {
    "ignoreUnknown": true,
    "ignore": []
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "noNonNullAssertion": "off"
      },
      "suspicious": {
        "noExplicitAny": "off"
      },
      "correctness": {
        "useExhaustiveDependencies": "error"
      },
      "complexity": {
        "noForEach": "off"
      },
      "a11y": {
        "useKeyWithClickEvents": "error",
        "useMediaCaption": "error"
      },
      "nursery": {
        "useImportRestrictions": "off"
      }
    }
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "jsxQuoteStyle": "single",
      "trailingCommas": "es5",
      "semicolons": "asNeeded",
      "arrowParentheses": "always",
      "bracketSpacing": true
    }
  }
}

```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "qasphere-mcp",
  "version": "0.2.1",
  "description": "MCP server for QA Sphere integration",
  "type": "module",
  "main": "dist/index.js",
  "bin": {
    "qasphere-mcp": "./dist/index.js"
  },
  "files": ["dist", "README.md", "LICENSE"],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Hypersequent/qasphere-mcp.git"
  },
  "keywords": ["mcp", "qasphere", "tms"],
  "author": "Hypersequent",
  "license": "MIT",
  "scripts": {
    "build": "tsc && chmod +x dist/index.js",
    "dev": "tsx src/index.ts",
    "lint": "biome lint --write .",
    "format": "biome format --write .",
    "inspector": "npx @modelcontextprotocol/inspector tsx src/index.ts",
    "test": "vitest run",
    "prepare": "husky"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.8.0",
    "axios": "^1.6.7",
    "dotenv": "^16.4.5",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@types/node": "^22.13.16",
    "husky": "^9.1.7",
    "lint-staged": "^15.5.1",
    "tsx": "^4.19.3",
    "typescript": "^5.8.2",
    "vitest": "^3.1.1"
  },
  "lint-staged": {
    "*.{ts,js}": ["biome lint --write", "biome format --write"],
    "*.json": "biome format --write"
  }
}

```

--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------

```typescript
// Type definition for the renameKeys map
export type RenameMap = {
  [key: string]: string | RenameMap
}

// Helper function to recursively rename keys and process nested structures
const renameKeyInObject = (obj: any, renameKeys: RenameMap): any => {
  // Base case: return non-objects/arrays as is
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  // Handle arrays: recursively process each element with the *original* full rename map
  if (Array.isArray(obj)) {
    // Important: Pass the original renameKeys map down, not a potentially nested part of it.
    return obj.map((item) => renameKeyInObject(item, renameKeys))
  }

  // Handle objects
  const newObj: Record<string, any> = {}
  for (const key in obj) {
    // Ensure it's an own property
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const currentValue = obj[key]
      const renameTarget = renameKeys[key]

      if (typeof renameTarget === 'string') {
        // Simple rename: Use the new key, recursively process the value with the *original* full rename map
        newObj[renameTarget] = currentValue
      } else if (
        typeof renameTarget === 'object' &&
        renameTarget !== null &&
        !Array.isArray(renameTarget)
      ) {
        // Nested rename rule provided: Keep the original key, recursively process the value with the specific *nested* rules
        newObj[key] = renameKeyInObject(currentValue, renameTarget as RenameMap)
      } else {
        newObj[key] = currentValue
      }
    }
  }
  return newObj
}

/**
 * Creates a JSON string from an object after renaming keys according to a map.
 * Handles nested objects and arrays, applying rules deeply.
 *
 * @param obj The input object or array.
 * @param renameKeys A map defining key renames. String values rename the key directly.
 *                   Object values indicate nested rules for the value of that key.
 *                 Example: { oldKey: 'newKey', nestedKey: { oldInnerKey: 'newInnerKey' } }
 * @returns A JSON string representation of the transformed object.
 */
export function JSONStringify(obj: any, renameKeys: RenameMap = {}): string {
  const transformedObj = renameKeyInObject(obj, renameKeys)
  // Use JSON.stringify with indentation for better readability if needed
  // return JSON.stringify(transformedObj, null, 2);
  return JSON.stringify(transformedObj)
}

```

--------------------------------------------------------------------------------
/src/LoggingTransport.ts:
--------------------------------------------------------------------------------

```typescript
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import type { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
import * as fs from 'node:fs'
import * as path from 'node:path'

/**
 * A wrapper transport that logs all MCP communication to a file
 */
export class LoggingTransport implements Transport {
  private wrapped: StdioServerTransport
  private logStream: fs.WriteStream
  private logFile: string
  sessionId?: string

  constructor(wrapped: StdioServerTransport, logFile: string) {
    // Store wrapped transport
    this.wrapped = wrapped

    // Set up logging
    this.logFile = logFile

    // Create log directory if it doesn't exist
    const logDir = path.dirname(this.logFile)
    if (!fs.existsSync(logDir)) {
      fs.mkdirSync(logDir, { recursive: true })
    }

    // Create log stream
    this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' })

    // Log initial connection information
    this.log({
      type: 'connection_info',
      timestamp: new Date().toISOString(),
      message: 'LoggingTransport initialized',
    })

    // Set up forwarding of events
    this.wrapped.onmessage = (message: JSONRPCMessage) => {
      this.log({
        type: 'received',
        timestamp: new Date().toISOString(),
        message,
      })
      if (this.onmessage) this.onmessage(message)
    }

    this.wrapped.onerror = (error: Error) => {
      this.log({
        type: 'error',
        timestamp: new Date().toISOString(),
        error: error.message,
        stack: error.stack,
      })
      if (this.onerror) this.onerror(error)
    }

    this.wrapped.onclose = () => {
      this.log({
        type: 'close',
        timestamp: new Date().toISOString(),
        message: 'Connection closed',
      })
      if (this.onclose) this.onclose()
      // Close the log stream when the connection closes
      this.logStream.end()
    }
  }

  // Implementation of Transport interface
  onclose?: () => void
  onerror?: (error: Error) => void
  onmessage?: (message: JSONRPCMessage) => void

  async start(): Promise<void> {
    return this.wrapped.start()
  }

  async close(): Promise<void> {
    return this.wrapped.close()
  }

  async send(message: JSONRPCMessage): Promise<void> {
    this.log({
      type: 'sent',
      timestamp: new Date().toISOString(),
      message,
    })
    return this.wrapped.send(message)
  }

  private log(data: any): void {
    try {
      this.logStream.write(`${JSON.stringify(data)}\n`)
    } catch (error) {
      console.error('Failed to write to log file:', error)
    }
  }
}

```

--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------

```typescript
export interface TestStep {
  description: string
  expected: string
}

export interface TestTag {
  id: number
  title: string
}

export interface TestFile {
  id: string
  fileName: string
  mimeType: string
  size: number
  url: string
}

export interface TestRequirement {
  id: string
  text: string
  url: string
}

export interface TestLink {
  text: string
  url: string
}

export interface TestCase {
  id: string // Unique identifier of the test case
  legacyId: string // Legacy identifier of the test case. Empty string if the test case has no legacy ID
  version: number // Version of the test case. Updates to test (except folder/pos) creates a new version
  title: string // Title of the test case
  seq: number // Sequence number of the test case. Test cases in a project are assigned incremental sequence numbers
  folderId: number // Identifier of the folder where the test case is placed
  pos: number // Ordered position (0 based) of the test case in its folder
  priority: 'high' | 'medium' | 'low' // Priority of the test case
  comment: string // Test description/precondition
  steps: TestStep[] // List of test case steps
  tags: TestTag[] // List of test case tags
  files: TestFile[] // List of files attached to the test case
  requirements: TestRequirement[] // Test case requirement (currently only single requirement is supported on UI)
  links: TestLink[] // Additional links relevant to the test case
  authorId: number // Unique identifier of the user who added the test case
  isDraft: boolean // Whether the test case is still in draft state
  isLatestVersion: boolean // Whether this is the latest version of the test case
  createdAt: string // Test case creation time (ISO 8601 format)
  updatedAt: string // Test case updation time (ISO 8601 format)
}

export interface TestCasesListResponse {
  total: number // Total number of filtered test cases
  page: number // Current page number
  limit: number // Number of test cases per page
  data: TestCase[] // List of test case objects
}

export interface ProjectLink {
  url: string
  text: string
}

export interface Project {
  id: string
  code: string
  title: string
  description: string
  overviewTitle: string
  overviewDescription: string
  links: ProjectLink[]
  createdAt: string
  updatedAt: string
  archivedAt: string | null
}

export interface TestFolder {
  id: number // Unique identifier for the folder
  title: string // Name of the folder
  comment: string // Additional notes or description
  pos: number // Position of the folder among its siblings
  parentId: number // ID of the parent folder (0 for root folders)
  projectId: string // ID of the project the folder belongs to
}

export interface TestFolderListResponse {
  total: number // Total number of items available
  page: number // Current page number
  limit: number // Number of items per page
  data: TestFolder[] // Array of folder objects
}

```

--------------------------------------------------------------------------------
/src/utils.test.ts:
--------------------------------------------------------------------------------

```typescript
import { describe, it, expect } from 'vitest'
import { JSONStringify, type RenameMap } from './utils'

describe('JSONStringify', () => {
  const baseObject = {
    a: 1,
    b: 2,
    c: { a: 11, b: 22, d: { a: 111 } },
    d: [
      { a: 50, x: 100 },
      { b: 60, a: 70 },
    ],
    e: null,
    f: [1, 2, 3],
    g: { a: 'keep_g' },
  }

  it('should return JSON string of object without changes when renameKeys is empty', () => {
    const result = JSONStringify(baseObject, {})
    expect(JSON.parse(result)).toEqual(baseObject)
  })

  it('should handle the first example: {a:z}', () => {
    const o = { a: 1, b: 2, c: { a: 11, b: 22 } }
    const renameMap: RenameMap = { a: 'z' }
    const expected = { z: 1, b: 2, c: { a: 11, b: 22 } }
    const result = JSONStringify(o, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should handle the second example: {c:{a:z}}', () => {
    const o = { a: 1, b: 2, c: { a: 11, b: 22 } }
    const renameMap: RenameMap = { c: { a: 'z' } }
    const expected = { a: 1, b: 2, c: { z: 11, b: 22 } }
    const result = JSONStringify(o, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should rename only specified top-level keys when using string values', () => {
    const renameMap: RenameMap = { a: 'z', b: 'y' }
    const expected = {
      z: 1, // renamed
      y: 2, // renamed
      c: { a: 11, b: 22, d: { a: 111 } }, // nested a/b untouched
      d: [
        { a: 50, x: 100 },
        { b: 60, a: 70 },
      ], // nested a/b untouched
      e: null,
      f: [1, 2, 3],
      g: { a: 'keep_g' }, // nested a untouched
    }
    const result = JSONStringify(baseObject, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should rename nested keys using nested rename map objects', () => {
    const renameMap: RenameMap = {
      c: { a: 'z', d: { a: 'k' } },
      g: { a: 'g_new' },
    }
    const expected = {
      a: 1,
      b: 2,
      c: { z: 11, b: 22, d: { k: 111 } }, // c.a renamed to z, c.d.a renamed to k
      d: [
        { a: 50, x: 100 },
        { b: 60, a: 70 },
      ], // d array untouched
      e: null,
      f: [1, 2, 3],
      g: { g_new: 'keep_g' }, // g.a renamed
    }
    const result = JSONStringify(baseObject, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should rename keys deeply within arrays if a matching nested rule exists', () => {
    // To rename 'a' inside the objects within the array 'd',
    // the rename map needs to target 'a' at the level where it occurs.
    const renameMap: RenameMap = { d: { a: 'z' } } // This applies universally
    const expected = {
      a: 1,
      b: 2,
      c: { a: 11, b: 22, d: { a: 111 } },
      d: [
        { z: 50, x: 100 },
        { b: 60, z: 70 },
      ],
      e: null,
      f: [1, 2, 3],
      g: { a: 'keep_g' },
    }
    const result = JSONStringify(baseObject, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should handle mixed simple and nested renaming rules', () => {
    const renameMap: RenameMap = { a: 'z', c: { b: 'y' } }
    const expected = {
      z: 1, // Renamed by top-level rule {a: 'z'}
      b: 2,
      c: { a: 11, y: 22, d: { a: 111 } }, // c.b renamed to y by nested rule, c.a and c.d.a untouched by *this* rule
      d: [
        { a: 50, x: 100 },
        { b: 60, a: 70 },
      ],
      e: null,
      f: [1, 2, 3],
      g: { a: 'keep_g' },
    }
    const result = JSONStringify(baseObject, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })

  it('should handle empty objects', () => {
    const renameMap: RenameMap = { a: 'z' }
    expect(JSON.parse(JSONStringify({}, renameMap))).toEqual({})
  })

  it('should handle objects with null values', () => {
    const obj = { a: 1, b: null }
    const renameMap: RenameMap = { a: 'z' }
    const expected = { z: 1, b: null }
    expect(JSON.parse(JSONStringify(obj, renameMap))).toEqual(expected)
  })

  it('should handle arrays directly if passed', () => {
    const arr = [{ a: 1 }, { a: 2 }]
    const renameMap: RenameMap = { a: 'z' }
    const expected = [{ z: 1 }, { z: 2 }]
    expect(JSON.parse(JSONStringify(arr, renameMap))).toEqual(expected)
  })

  it('should handle non-string/object values in renameKeys gracefully (treat as no-op for that key)', () => {
    // The type system prevents this, but testing javascript flexibility
    const renameMap: any = { a: true, b: 'y' }
    const obj = { a: 1, b: 2, c: 3 }
    const expected = { a: 1, y: 2, c: 3 } // 'a' is not renamed (invalid rule type), 'b' is
    const result = JSONStringify(obj, renameMap)
    expect(JSON.parse(result)).toEqual(expected)
  })
})

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/usr/bin/env node

import type { Project, TestCase, TestCasesListResponse, TestFolderListResponse } from './types.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LoggingTransport } from './LoggingTransport.js'
import { JSONStringify } from './utils.js'
import dotenv from 'dotenv'
import axios from 'axios'
import { z } from 'zod'

dotenv.config()

// Validate required environment variables
const requiredEnvVars = ['QASPHERE_TENANT_URL', 'QASPHERE_API_KEY']
for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    console.error(`Error: Missing required environment variable: ${envVar}`)
    process.exit(1)
  }
}

const QASPHERE_TENANT_URL = ((url: string) => {
  let tenantUrl = url
  if (
    !tenantUrl.toLowerCase().startsWith('http://') &&
    !tenantUrl.toLowerCase().startsWith('https://')
  ) {
    tenantUrl = `https://${tenantUrl}`
  }
  if (tenantUrl.endsWith('/')) {
    tenantUrl = tenantUrl.slice(0, -1)
  }
  return tenantUrl
})(process.env.QASPHERE_TENANT_URL!)

const QASPHERE_API_KEY = process.env.QASPHERE_API_KEY!

// Create MCP server
const server = new McpServer({
  name: 'qasphere-mcp',
  version: process.env.npm_package_version || '0.0.0',
  description: 'QA Sphere MCP server for fetching test cases and projects.',
})

// Add the get_test_case tool
server.tool(
  'get_test_case',
  `Get a test case from QA Sphere using a marker in the format PROJECT_CODE-SEQUENCE (e.g., BDI-123). You can use URLs like: ${QASPHERE_TENANT_URL}/project/%PROJECT_CODE%/tcase/%SEQUENCE%?any Extract %PROJECT_CODE% and %SEQUENCE% from the URL and use them as the marker.`,
  {
    marker: z
      .string()
      .regex(/^[A-Z0-9]+-\d+$/, 'Marker must be in format PROJECT_CODE-SEQUENCE (e.g., BDI-123)')
      .describe('Test case marker in format PROJECT_CODE-SEQUENCE (e.g., BDI-123)'),
  },
  async ({ marker }: { marker: string }) => {
    try {
      const [projectId, sequence] = marker.split('-')
      const response = await axios.get<TestCase>(
        `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectId}/tcase/${sequence}`,
        {
          headers: {
            Authorization: `ApiKey ${QASPHERE_API_KEY}`,
            'Content-Type': 'application/json',
          },
        }
      )

      const testCase = response.data

      // Sanity check for required fields
      if (!testCase.id || !testCase.title || !testCase.version === undefined) {
        throw new Error('Invalid test case data: missing required fields (id, title, or version)')
      }

      return {
        content: [
          {
            type: 'text',
            text: JSONStringify(testCase, {
              comment: 'precondition',
              steps: { description: 'action', expected: 'expected_result' },
            }),
          },
        ],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        throw new Error(
          `Failed to fetch test case: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

server.tool(
  'get_project',
  `Get a project information from QA Sphere using a project code (e.g., BDI). You can extract PROJECT_CODE from URLs ${QASPHERE_TENANT_URL}/project/%PROJECT_CODE%/...`,
  {
    projectCode: z
      .string()
      .regex(/^[A-Z0-9]+$/, 'Marker must be in format PROJECT_CODE (e.g., BDI)')
      .describe('Project code identifier (e.g., BDI)'),
  },
  async ({ projectCode }: { projectCode: string }) => {
    try {
      const response = await axios.get<Project>(
        `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}`,
        {
          headers: {
            Authorization: `ApiKey ${QASPHERE_API_KEY}`,
            'Content-Type': 'application/json',
          },
        }
      )

      const projectData = response.data
      if (!projectData.id || !projectData.title) {
        throw new Error('Invalid project data: missing required fields (id or title)')
      }

      return {
        content: [{ type: 'text', text: JSON.stringify(projectData) }],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 404) {
          throw new Error(`Project with code '${projectCode}' not found.`)
        }
        throw new Error(
          `Failed to fetch project: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

server.tool(
  'list_projects',
  'Get a list of all projects from current QA Sphere TMS account (qasphere.com)',
  {},
  async () => {
    try {
      const response = await axios.get(`${QASPHERE_TENANT_URL}/api/public/v0/project`, {
        headers: {
          Authorization: `ApiKey ${QASPHERE_API_KEY}`,
          'Content-Type': 'application/json',
        },
      })

      const projectsData = response.data
      if (!Array.isArray(projectsData.projects)) {
        throw new Error('Invalid response: expected an array of projects')
      }

      // if array is non-empty check if object has id and title fields
      if (projectsData.projects.length > 0) {
        const firstProject = projectsData.projects[0]
        if (!firstProject.id || !firstProject.title) {
          throw new Error('Invalid project data: missing required fields (id or title)')
        }
      }

      return {
        content: [{ type: 'text', text: JSON.stringify(projectsData) }],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        throw new Error(
          `Failed to fetch projects: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

server.tool(
  'list_test_cases',
  'List test cases from a project in QA Sphere. Supports pagination and various filtering options. Usually it makes sense to call get_project tool first to get the project context.',
  {
    projectCode: z
      .string()
      .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)')
      .describe('Project code identifier (e.g., BDI)'),
    page: z.number().optional().describe('Page number for pagination'),
    limit: z.number().optional().default(20).describe('Number of items per page'),
    sortField: z
      .enum([
        'id',
        'seq',
        'folder_id',
        'author_id',
        'pos',
        'title',
        'priority',
        'created_at',
        'updated_at',
        'legacy_id',
      ])
      .optional()
      .describe('Field to sort results by'),
    sortOrder: z
      .enum(['asc', 'desc'])
      .optional()
      .describe('Sort direction (ascending or descending)'),
    search: z.string().optional().describe('Search term to filter test cases'),
    include: z
      .array(z.enum(['steps', 'tags', 'project', 'folder', 'path']))
      .optional()
      .describe('Related data to include in the response'),
    folders: z.array(z.number()).optional().describe('Filter by folder IDs'),
    tags: z.array(z.number()).optional().describe('Filter by tag IDs'),
    priorities: z
      .array(z.enum(['high', 'medium', 'low']))
      .optional()
      .describe('Filter by priority levels'),
    draft: z.boolean().optional().describe('Filter draft vs published test cases'),
  },
  async ({
    projectCode,
    page,
    limit = 20,
    sortField,
    sortOrder,
    search,
    include,
    folders,
    tags,
    priorities,
    draft,
  }) => {
    try {
      // Build query parameters
      const params = new URLSearchParams()

      if (page !== undefined) params.append('page', page.toString())
      if (limit !== undefined) params.append('limit', limit.toString())
      if (sortField) params.append('sortField', sortField)
      if (sortOrder) params.append('sortOrder', sortOrder)
      if (search) params.append('search', search)

      // Add array parameters
      if (include) include.forEach((item) => params.append('include', item))
      if (folders) folders.forEach((item) => params.append('folders', item.toString()))
      if (tags) tags.forEach((item) => params.append('tags', item.toString()))
      if (priorities) priorities.forEach((item) => params.append('priorities', item))

      if (draft !== undefined) params.append('draft', draft.toString())

      const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tcase`

      const response = await axios.get<TestCasesListResponse>(url, {
        params,
        headers: {
          Authorization: `ApiKey ${QASPHERE_API_KEY}`,
          'Content-Type': 'application/json',
        },
      })

      const testCasesList = response.data

      // Basic validation of response
      if (!testCasesList || !Array.isArray(testCasesList.data)) {
        throw new Error('Invalid response: expected a list of test cases')
      }

      // check for other fields from TestCasesListResponse
      if (
        testCasesList.total === undefined ||
        testCasesList.page === undefined ||
        testCasesList.limit === undefined
      ) {
        throw new Error('Invalid response: missing required fields (total, page, or limit)')
      }

      // if array is non-empty check if object has id and title fields
      if (testCasesList.data.length > 0) {
        const firstTestCase = testCasesList.data[0]
        if (!firstTestCase.id || !firstTestCase.title) {
          throw new Error('Invalid test case data: missing required fields (id or title)')
        }
      }

      return {
        content: [
          {
            type: 'text',
            text: JSONStringify(testCasesList, {
              data: {
                comment: 'precondition',
                steps: {
                  description: 'action',
                  expected: 'expected_result',
                },
              },
            }),
          },
        ],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 404) {
          throw new Error(`Project with code '${projectCode}' not found.`)
        }
        throw new Error(
          `Failed to fetch test cases: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

server.tool(
  'list_test_cases_folders',
  'List folders for test cases within a specific QA Sphere project. Allows pagination and sorting.',
  {
    projectCode: z
      .string()
      .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)')
      .describe('Project code identifier (e.g., BDI)'),
    page: z.number().optional().describe('Page number for pagination'),
    limit: z.number().optional().default(100).describe('Number of items per page'),
    sortField: z
      .enum(['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at'])
      .optional()
      .describe('Field to sort results by'),
    sortOrder: z
      .enum(['asc', 'desc'])
      .optional()
      .describe('Sort direction (ascending or descending)'),
  },
  async ({ projectCode, page, limit = 100, sortField, sortOrder }) => {
    try {
      // Build query parameters
      const params = new URLSearchParams()

      if (page !== undefined) params.append('page', page.toString())
      if (limit !== undefined) params.append('limit', limit.toString())
      if (sortField) params.append('sortField', sortField)
      if (sortOrder) params.append('sortOrder', sortOrder)

      const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tcase/folders`

      const response = await axios.get<TestFolderListResponse>(url, {
        params,
        headers: {
          Authorization: `ApiKey ${QASPHERE_API_KEY}`,
          'Content-Type': 'application/json',
        },
      })

      const folderList = response.data

      // Basic validation of response
      if (!folderList || !Array.isArray(folderList.data)) {
        throw new Error('Invalid response: expected a list of folders')
      }

      // check for other fields from TestFolderListResponse
      if (
        folderList.total === undefined ||
        folderList.page === undefined ||
        folderList.limit === undefined
      ) {
        throw new Error('Invalid response: missing required fields (total, page, or limit)')
      }

      // if array is non-empty check if object has id and title fields
      if (folderList.data.length > 0) {
        const firstFolder = folderList.data[0]
        if (firstFolder.id === undefined || !firstFolder.title) {
          throw new Error('Invalid folder data: missing required fields (id or title)')
        }
      }

      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(folderList), // Use standard stringify, no special mapping needed for folders
          },
        ],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 404) {
          throw new Error(`Project with code '${projectCode}' not found.`)
        }
        throw new Error(
          `Failed to fetch test case folders: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

server.tool(
  'list_test_cases_tags',
  'List all tags defined within a specific QA Sphere project.',
  {
    projectCode: z
      .string()
      .regex(/^[A-Z0-9]+$/, 'Project code must be in format PROJECT_CODE (e.g., BDI)')
      .describe('Project code identifier (e.g., BDI)'),
  },
  async ({ projectCode }: { projectCode: string }) => {
    try {
      const url = `${QASPHERE_TENANT_URL}/api/public/v0/project/${projectCode}/tag`

      const response = await axios.get<{
        tags: Array<{ id: number; title: string }>
      }>(url, {
        headers: {
          Authorization: `ApiKey ${QASPHERE_API_KEY}`,
          'Content-Type': 'application/json',
        },
      })

      const tagsData = response.data

      // Basic validation of response
      if (!tagsData || !Array.isArray(tagsData.tags)) {
        throw new Error('Invalid response: expected an object with a "tags" array')
      }

      // if array is non-empty check if object has id and title fields
      if (tagsData.tags.length > 0) {
        const firstTag = tagsData.tags[0]
        if (firstTag.id === undefined || !firstTag.title) {
          throw new Error('Invalid tag data: missing required fields (id or title)')
        }
      }

      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(tagsData), // Use standard stringify
          },
        ],
      }
    } catch (error: unknown) {
      if (axios.isAxiosError(error)) {
        if (error.response?.status === 404) {
          throw new Error(`Project with identifier '${projectCode}' not found.`)
        }
        throw new Error(
          `Failed to fetch project tags: ${error.response?.data?.message || error.message}`
        )
      }
      throw error
    }
  }
)

// Start receiving messages on stdin and sending messages on stdout
async function startServer() {
  // Create base transport
  const baseTransport = new StdioServerTransport()

  // Wrap with logging transport if MCP_LOG_TO_FILE is set
  let transport: Transport = baseTransport

  if (process.env.MCP_LOG_TO_FILE) {
    const logFilePath = process.env.MCP_LOG_TO_FILE
    console.error(`MCP: Logging to file: ${logFilePath}`)
    transport = new LoggingTransport(baseTransport, logFilePath)
  }

  await server.connect(transport)
  console.error('QA Sphere MCP server started')
}

startServer().catch(console.error)

```