Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- [QTM4J] Added optional `folderId` support to `create_test_case` and `create_test_cycle`. If omitted, the asset is created in the `MCP Generated` folder.

### Fixed

- [QTM4J] Fixed `search_test_cases` folder filtering to use `folderId` instead of `folders`, matching the backend API contract.

## [0.41.0] - 2026-09-16

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/products/SmartBear MCP Server/qtm4j-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The following environment variables configure the QTM4J integration:
- priority names to include (`priority`) — e.g., `["High", "Medium"]`
- label names to include (`labels`) — e.g., `["Release_1", "Sprint 1"]`
- component names to include (`components`) — e.g., `["UI", "Cloud"]`
- folder IDs to filter by (`folders`)
- folder ID to filter by (`folderId`) — numeric ID; right-click a folder in QTM4J and select "Copy Folder Id"
- assignee Jira account IDs (`assignee`)
- reporter Jira account IDs (`reporter`)
- automation status (`isAutomated`)
Expand Down Expand Up @@ -123,7 +123,7 @@ The following environment variables configure the QTM4J integration:
- optional estimated time in HH:MM:SS format (`estimatedTime`) — e.g., `"01:30:00"`
- optional label names to attach (`labels`) — e.g., `["Release_1", "Sprint 1"]`
- optional component names to attach (`components`) — e.g., `["UI", "Backend"]`
- optional folder ID to place the test case in (`folderId`) — defaults to the `MCP Generated` folder if not provided
- optional numeric folder ID (`folderId`) — defaults to `MCP Generated`; right-click the target folder in QTM4J and select "Copy Folder Id"
- **Returns**: The created test case key (e.g., `SCRUM-TC-146`) and ID.
- **Use case**: Adding new test cases with metadata, associating them with labels and components, organizing them into folders.

Expand Down Expand Up @@ -262,9 +262,9 @@ The following environment variables configure the QTM4J integration:
- optional component names to attach (`components`) — e.g., `["UI", "Cloud"]`
- optional planned start date (`plannedStartDate`) — format: `dd/MMM/yyyy HH:mm` e.g., `"10/May/2026 00:00"`
- optional planned end date (`plannedEndDate`) — format: `dd/MMM/yyyy HH:mm`
- optional numeric folder ID (`folderId`) — defaults to `MCP Generated`; right-click the target folder in QTM4J and select "Copy Folder Id"
- **Returns**: The created test cycle key (e.g., `SCRUM-TR-218`) and ID.
- **Use case**: Creating sprint cycles, release cycles, or environment-specific cycles; organizing test cases for a planned execution window.
- **Note**: All cycles are placed in the `MCP Generated` folder automatically.

### Update Operations

Expand Down
17 changes: 8 additions & 9 deletions src/common/__snapshots__/server-definitions.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -8902,7 +8902,7 @@ Expected Output: description updated only. Field IDs auto-resolved from project
**Parameters:**
- summary (string) *required*: Test case summary/title
- description (string): Test case description
- folderId (number): Folder ID to place the test case in
- folderId (number): Numeric folder ID where the test case will be created. If omitted, the test case is created in the 'MCP Generated' folder automatically.
- priority (string): Priority name (e.g., 'High', 'Medium', 'Low'). Auto-resolved to ID.
- status (string): Status name (e.g., 'To Do', 'In Progress', 'Done'). Auto-resolved to ID.
- assignee (string): Assignee account ID
Expand Down Expand Up @@ -8971,7 +8971,7 @@ Expected Output: Test case created with resolved priority and status IDs
\`\`\`
Expected Output: Test case created with resolved labels/components/priority/status and 3 steps

**Hints:** 1. PREREQUISITE: set_project_context must be called before this tool. NEVER auto-select a project. 2. Priority and status values were returned by set_project_context. Use NLP to map user input (e.g., 'Major' → 'High', 'Critical' → 'Blocker'). 3. If priority or status name is not found, the operation proceeds without that field and a warning is returned. 4. Labels and components are resolved on demand. If a name is not found, it is skipped with a warning. 5. Steps: ALWAYS include all three fields — stepDetails, testData, and expectedResult. Generate reasonable values if not provided. 6. folderId is optional. assignee and reporter accept Jira account IDs.",
**Hints:** 1. PREREQUISITE: set_project_context must be called before this tool. NEVER auto-select a project. 2. Priority and status values were returned by set_project_context. Use NLP to map user input (e.g., 'Major' → 'High', 'Critical' → 'Blocker'). 3. If priority or status name is not found, the operation proceeds without that field and a warning is returned. 4. Labels and components are resolved on demand. If a name is not found, it is skipped with a warning. 5. Steps: ALWAYS include all three fields — stepDetails, testData, and expectedResult. Generate reasonable values if not provided. 6. FOLDER ID: folderId is optional. If omitted, defaults to the 'MCP Generated' folder. To place in a specific folder, ask the user to right-click the target folder in QTM4J and select 'Copy Folder Id' — never try to look it up.",
"inputSchema": [
"assignee",
"components",
Expand Down Expand Up @@ -9000,7 +9000,7 @@ Expected Output: Test case created with resolved labels/components/priority/stat
"readOnlyHint": false,
"title": "QTM4J: Create Test Cycle",
},
"description": "Create a new test cycle in a QTM4J project. Supports auto-resolving human-readable names for priority and status. Always creates in the 'MCP Generated' folder. projectId is injected automatically from the active project context.
"description": "Create a new test cycle in a QTM4J project. Supports auto-resolving human-readable names for priority and status. projectId is injected automatically from the active project context.

**Toolset:** Test Cycles

Expand All @@ -9009,6 +9009,7 @@ Expected Output: Test case created with resolved labels/components/priority/stat
- description (string): Detailed description of the test cycle. Max 65 535 characters.
- priority (string): Priority name (e.g., 'High', 'Medium', 'Low'). Auto-resolved to ID.
- status (string): Status name (e.g., 'To Do', 'In Progress', 'Done'). Auto-resolved to ID.
- folderId (number): Numeric folder ID where the test cycle will be created. If omitted, the cycle is created in the 'MCP Generated' folder automatically.
- assignee (string): Assignee account ID
- reporter (string): Reporter account ID
- labels (array): List of label names (e.g., ['Release_1', 'Sprint 1']). Auto-resolved to IDs.
Expand Down Expand Up @@ -9050,11 +9051,12 @@ Expected Output: Test cycle created with key 'SCRUM-TR-xxx'
\`\`\`
Expected Output: Test cycle created with resolved priority, status, labels, and components

**Hints:** 1. PREREQUISITE: set_project_context must be called before this tool. NEVER auto-select a project. 2. If any priority, status, label, or component name cannot be resolved, the cycle is still created but a warning is returned. Suggest the closest available value from the set_project_context response and ask the user to confirm before retrying. 3. All cycles are placed in the 'MCP Generated' folder — do not pass folderId. 4. Date format: 'dd/MMM/yyyy HH:mm' e.g. '10/May/2026 00:00'. Month must be capitalised. plannedStartDate must be ≤ plannedEndDate.",
**Hints:** 1. PREREQUISITE: set_project_context must be called before this tool. NEVER auto-select a project. 2. If any priority, status, label, or component name cannot be resolved, the cycle is still created but a warning is returned. Suggest the closest available value from the set_project_context response and ask the user to confirm before retrying. 3. FOLDER ID: folderId is optional. If omitted, defaults to the 'MCP Generated' folder. To place in a specific folder, ask the user to right-click the target folder in QTM4J and select 'Copy Folder Id' — never try to look it up. 4. Date format: 'dd/MMM/yyyy HH:mm' e.g. '10/May/2026 00:00'. Month must be capitalised. plannedStartDate must be ≤ plannedEndDate.",
"inputSchema": [
"assignee",
"components",
"description",
"folderId",
"labels",
"plannedEndDate",
"plannedStartDate",
Expand Down Expand Up @@ -10433,17 +10435,14 @@ Expected Output: Test cases with all available fields explicitly requested
\`\`\`json
{
"filter": {
"folders": [
123,
456
],
"folderId": 123,
"fixVersions": [
789
]
}
}
\`\`\`
Expected Output: Test cases in the specified folders and fix versions
Expected Output: Test cases in the specified folder and fix version

11. Complex filter: multiple criteria combined with multi-field sort
\`\`\`json
Expand Down
10 changes: 9 additions & 1 deletion src/qtm4j/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ export const TOOL_NAMES = {
CREATE_TEST_CYCLE: {
TITLE: "Create Test Cycle",
SUMMARY:
"Create a new test cycle in a QTM4J project. Supports auto-resolving human-readable names for priority and status. Always creates in the 'MCP Generated' folder. projectId is injected automatically from the active project context.",
"Create a new test cycle in a QTM4J project. Supports auto-resolving human-readable names for priority and status. projectId is injected automatically from the active project context.",
},

/** Update Test Cycle tool */
Expand Down Expand Up @@ -801,6 +801,14 @@ export const SORT_DEFAULTS = {
TEST_CYCLES: "key:asc",
} as const;

/**
* Folder Defaults
*/
export const DEFAULT_FOLDER_NAMES = {
/** Default folder name used when no folderId is supplied by the user */
MCP_GENERATED: "MCP Generated",
} as const;

/**
* Empty Values
*/
Expand Down
10 changes: 6 additions & 4 deletions src/qtm4j/schema/get-test-case.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ export const SearchTestCaseFilter = zod
"Component names to include (OR logic within array). Example: ['UI', 'Cloud', 'API']. " +
"Use exact component names as configured in the project.",
),
folders: zod
.array(zod.number())
folderId: zod
.number()
.int()
.positive()
.optional()
.describe(
"Folder IDs (numeric, OR logic within array). Example: [123, 456]. " +
"Retrieve folder IDs from the project's folder structure.",
"Numeric folder ID to filter test cases by. " +
"Right-click the target folder in QTM4J and select 'Copy Folder Id' to get this value.",
),
assignee: zod
.array(zod.string())
Expand Down
7 changes: 6 additions & 1 deletion src/qtm4j/schema/test-case.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ export const CreateTestCaseBody = zod.object({
description: zod.string().optional().describe("Test case description"),
folderId: zod
.number()
.int()
.positive()
.optional()
.describe("Folder ID to place the test case in"),
.describe(
"Numeric folder ID where the test case will be created. " +
"If omitted, the test case is created in the 'MCP Generated' folder automatically.",
),
priority: zod
.string()
.optional()
Expand Down
13 changes: 12 additions & 1 deletion src/qtm4j/schema/test-cycle.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import * as zod from "zod";

/**
* projectId and folderId are injected automatically by the tool.
* projectId is injected automatically by the tool.
* folderId is optional — supplied by the user as a numeric ID, or defaulted to
* the 'MCP Generated' folder by the tool when omitted.
* priority, status, labels, and components accept human-readable names
* and are auto-resolved to numeric IDs before the API call.
*/
Expand Down Expand Up @@ -35,6 +37,15 @@ export const CreateTestCycleBody = zod.object({
.describe(
"Status name (e.g., 'To Do', 'In Progress', 'Done'). Auto-resolved to ID.",
),
folderId: zod
.number()
.int()
.positive()
.optional()
.describe(
"Numeric folder ID where the test cycle will be created. " +
"If omitted, the cycle is created in the 'MCP Generated' folder automatically.",
),
assignee: zod.string().optional().describe("Assignee account ID"),
reporter: zod.string().optional().describe("Reporter account ID"),
labels: zod
Expand Down
54 changes: 53 additions & 1 deletion src/qtm4j/tool/test-case/create-test-case.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENDPOINTS } from "../../config/constants";
import { DEFAULT_FOLDER_NAMES, ENDPOINTS } from "../../config/constants";
import { CreateTestCase } from "./create-test-case";

describe("CreateTestCase", () => {
Expand Down Expand Up @@ -190,6 +190,58 @@ describe("CreateTestCase", () => {

expect(result.structuredContent).toBeDefined();
expect(result.content).toEqual([]);
// folderId must not be clobbered — numeric value must reach the API
expect(mockApiClient.post).toHaveBeenCalledWith(
ENDPOINTS.CREATE_TEST_CASE,
expect.objectContaining({ folderId: 5000 }),
);
});

it("should skip folder resolution and pass folderId numeric directly", async () => {
mockApiClient.post.mockResolvedValueOnce({
id: "1",
key: "PROJ-TC-1",
versionNo: 1,
summary: "TC",
});

await instance.handle({ summary: "TC", folderId: 99 });

// FOLDER excluded from activeFieldConfig → only 4 resolvers: PRIORITY, STATUS, COMPONENTS, LABELS
expect(mockFieldResolver.getResolver).toHaveBeenCalledTimes(4);
expect(mockApiClient.post).toHaveBeenCalledWith(
ENDPOINTS.CREATE_TEST_CASE,
expect.objectContaining({ folderId: 99 }),
);
});

it("should default folderId to 'MCP Generated' when not provided", async () => {
mockApiClient.post.mockResolvedValueOnce({
id: "1",
key: "PROJ-TC-1",
versionNo: 1,
summary: "TC",
});

await instance.handle({ summary: "TC" });

// Capture count before the assertion call to getResolver() adds to it
const resolverCallCount = mockFieldResolver.getResolver.mock.calls.length;
const resolveCall = mockFieldResolver
.getResolver()
.resolve.mock.calls.find((call: any[]) => call[0] === "folderId");
expect(resolveCall).toBeDefined();
expect(resolveCall[2]).toMatchObject({
folderId: DEFAULT_FOLDER_NAMES.MCP_GENERATED,
});
// All 5 resolvers active: PRIORITY, STATUS, FOLDER, COMPONENTS, LABELS
expect(resolverCallCount).toBe(5);
});

it("should reject non-positive folderId", async () => {
await expect(
instance.handle({ summary: "TC", folderId: 0 }),
).rejects.toThrow();
});
});
});
28 changes: 21 additions & 7 deletions src/qtm4j/tool/test-case/create-test-case.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Tool } from "../../../common/tools";
import type { ToolParams } from "../../../common/types";
import type { Qtm4jClient } from "../../client";
import { ENDPOINTS, TOOL_NAMES, TOOLSETS } from "../../config/constants";
import {
DEFAULT_FOLDER_NAMES,
ENDPOINTS,
TOOL_NAMES,
TOOLSETS,
} from "../../config/constants";
import { InputField, ResolverKeys } from "../../config/field-resolution.types";
import {
CreateTestCaseBody,
Expand Down Expand Up @@ -115,7 +120,7 @@ export class CreateTestCase extends Tool<Qtm4jClient> {
"If priority or status name is not found, the operation proceeds without that field and a warning is returned.",
"Labels and components are resolved on demand. If a name is not found, it is skipped with a warning.",
"Steps: ALWAYS include all three fields — stepDetails, testData, and expectedResult. Generate reasonable values if not provided.",
"folderId is optional. assignee and reporter accept Jira account IDs.",
"FOLDER ID: folderId is optional. If omitted, defaults to the 'MCP Generated' folder. To place in a specific folder, ask the user to right-click the target folder in QTM4J and select 'Copy Folder Id' — never try to look it up.",
],
outputDescription:
"JSON object with test case ID, key, version number, and summary. Warnings included if any fields were skipped.",
Expand All @@ -126,15 +131,24 @@ export class CreateTestCase extends Tool<Qtm4jClient> {
handle = async (rawArgs: any) => {
const fieldResolver = this.client.getResolverRegistry();
const context = fieldResolver.requireProjectContext();
const body = {
...(CreateTestCaseBody.parse(rawArgs) as Record<string, unknown>),

const parsed = CreateTestCaseBody.parse(rawArgs) as Record<string, unknown>;
const body: Record<string, unknown> = {
...parsed,
projectId: String(context.projectId),
folderId: "MCP Generated",
};
const warnings: string[] = [];

// Numeric folderId → use as-is, skip resolver. If Absent → default to "MCP Generated".
const activeFieldConfig = { ...FIELD_CONFIG };
if (typeof body[InputField.FOLDER] === "number") {
delete activeFieldConfig[InputField.FOLDER];
} else {
body[InputField.FOLDER] = DEFAULT_FOLDER_NAMES.MCP_GENERATED;
}

const warnings: string[] = [];
await Promise.all(
Object.entries(FIELD_CONFIG).map(([inputField, resolverKey]) =>
Object.entries(activeFieldConfig).map(([inputField, resolverKey]) =>
fieldResolver
.getResolver(resolverKey)
.resolve(inputField, resolverKey, body, context, warnings),
Expand Down
16 changes: 16 additions & 0 deletions src/qtm4j/tool/test-case/get-test-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ describe("SearchTestCases", () => {
});
});

it("should send folderId in filter body when provided", async () => {
mockApiClient.post.mockResolvedValueOnce(mockResponse);

await instance.handle({ filter: { folderId: 16 } });

expect(mockApiClient.post).toHaveBeenCalledWith(expect.any(String), {
filter: { folderId: 16, projectId: "10000" },
});
});

it("should reject non-positive folderId in filter", async () => {
await expect(
instance.handle({ filter: { folderId: 0 } }),
).rejects.toThrow();
});

it("should throw when project context is not set", async () => {
mockRegistry.requireProjectContext.mockImplementation(() => {
throw new Error("No active project set");
Expand Down
4 changes: 2 additions & 2 deletions src/qtm4j/tool/test-case/get-test-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,11 @@ export class GetTestCases extends Tool<Qtm4jClient> {
description: "Filter by folder and fix version",
parameters: {
filter: {
folders: [123, 456],
folderId: 123,
fixVersions: [789],
},
},
expectedOutput: "Test cases in the specified folders and fix versions",
expectedOutput: "Test cases in the specified folder and fix version",
},
{
description:
Expand Down
27 changes: 24 additions & 3 deletions src/qtm4j/tool/test-cycle/create-test-cycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENDPOINTS } from "../../config/constants";
import { DEFAULT_FOLDER_NAMES, ENDPOINTS } from "../../config/constants";
import { CreateTestCycle } from "./create-test-cycle";

describe("CreateTestCycle", () => {
Expand Down Expand Up @@ -79,7 +79,7 @@ describe("CreateTestCycle", () => {
expect(result.content).toEqual([]);
});

it("should always set folderId to 'MCP Generated' before resolution", async () => {
it("should default folderId to 'MCP Generated' when not provided", async () => {
mockApiClient.post.mockResolvedValueOnce(MINIMAL_RESPONSE);

await instance.handle(MINIMAL_ARGS);
Expand All @@ -88,7 +88,28 @@ describe("CreateTestCycle", () => {
.getResolver()
.resolve.mock.calls.find((call: any[]) => call[0] === "folderId");
expect(resolveCall).toBeDefined();
expect(resolveCall[2]).toMatchObject({ folderId: "MCP Generated" });
expect(resolveCall[2]).toMatchObject({
folderId: DEFAULT_FOLDER_NAMES.MCP_GENERATED,
});
});

it("should use user-supplied folderId and skip folder resolution", async () => {
mockApiClient.post.mockResolvedValueOnce(MINIMAL_RESPONSE);

await instance.handle({ ...MINIMAL_ARGS, folderId: 42 });

// FOLDER excluded from activeFieldConfig → only 4 resolvers: PRIORITY, STATUS, LABELS, COMPONENTS
expect(mockFieldResolver.getResolver).toHaveBeenCalledTimes(4);
expect(mockApiClient.post).toHaveBeenCalledWith(
ENDPOINTS.CREATE_TEST_CYCLE,
expect.objectContaining({ folderId: 42 }),
);
});

it("should reject non-positive folderId", async () => {
await expect(
instance.handle({ ...MINIMAL_ARGS, folderId: 0 }),
).rejects.toThrow();
});

it("should call the create endpoint with projectId from context", async () => {
Expand Down
Loading
Loading