Skip to main content
Glama
TCSoftInc

TestCollab MCP Server

by TCSoftInc

create_test_plan

Create structured test plans in TestCollab with optional test cases, configurations, and team assignments to organize testing workflows.

Instructions

Create a test plan in TestCollab using a single MCP tool call.

Before calling this tool:

  • Ask follow-up questions for missing required information.

  • Do not infer or auto-generate required values like project_id.

Execution flow:

  1. POST /testplans

  2. POST /testplantestcases/bulkAdd (optional)

  3. POST /testplanconfigurations (optional)

  4. POST /testplans/assign (optional)

Optional:

  • project_id

  • title (defaults to "Test Plan DD Month YYYY HH:mm:ss" if omitted)

  • description

  • priority (0=Low, 1=Normal, 2=High)

  • test_plan_folder (ID or title)

  • release (ID or title)

  • start_date, end_date

  • custom_fields

  • test_cases (test_case_ids/selector/assignee; assignee supports user ID/"me"/name)

  • configurations

  • assignment (supports user IDs/"me"/names; if user says "assign to me", use "me")

Example: { "project_id": 16, "title": "Release 2.9 Regression", "priority": 1, "test_cases": { "test_case_ids": [101, 102, 103] }, "configurations": [ [{ "field": "Browser", "value": "Chrome" }, { "field": "OS", "value": "Windows" }] ], "assignment": { "executor": "team", "assignment_criteria": "testCase", "assignment_method": "automatic", "user_ids": [27, 31] } }

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
project_idNoProject ID (optional if TC_DEFAULT_PROJECT is set)
titleNoTest plan title (optional; defaults to "Test Plan DD Month YYYY HH:mm:ss")
descriptionNoTest plan description (HTML supported)
priorityNoPriority: 0=Low, 1=Normal, 2=High
test_plan_folderNoTest plan folder ID or title (null to place at root)
releaseNoRelease ID or title
start_dateNoPlanned start date (YYYY-MM-DD)
end_dateNoPlanned end date (YYYY-MM-DD)
custom_fieldsNoArray of test plan custom field values
test_casesNoTest cases to bulk-add immediately after plan creation
configurationsNoConfiguration matrix to attach to the test plan
assignmentNoAssignment payload to execute after creation

Implementation Reference

  • The handler function that executes the "create_test_plan" tool logic.
    export async function handleCreateTestPlan(args: unknown): Promise<ToolResponse> {
      const parsed = createTestPlanSchema.safeParse(args);
      if (!parsed.success) {
        const missingFields = extractMissingRequiredFields(parsed.error.errors);
        if (missingFields.length > 0) {
          return toMissingInfoError(
            "MISSING_REQUIRED_INFO",
            "Missing required information to create a test plan.",
            missingFields
          );
        }
        return toError("VALIDATION_ERROR", "Invalid input parameters", parsed.error.errors);
      }
    
      const {
        project_id,
        title,
        description,
        priority,
        test_plan_folder,
        release,
        start_date,
        end_date,
        custom_fields,
        test_cases,
        configurations,
        assignment,
      } = parsed.data;
    
      const normalizedTitle = normalizeString(title) ?? buildDefaultTestPlanTitle();
    
      const requestContext = getRequestContext();
      const envConfig = requestContext ? null : getConfig();
      const resolvedProjectId =
        project_id ?? requestContext?.defaultProjectId ?? envConfig?.defaultProjectId;
    
      if (!resolvedProjectId) {
        return toMissingInfoError(
          "MISSING_PROJECT_ID",
          "project_id is required. Either provide it in the request or set TC_DEFAULT_PROJECT.",
          ["project_id"]
        );
      }
    
      const assignmentUsers = normalizeAssignmentUsers(assignment?.user_ids);
      if (assignmentUsers.invalidValues.length > 0) {
        return toError(
          "INVALID_ASSIGNMENT_USERS",
          "assignment.user_ids contains invalid values.",
          {
            invalid_values: assignmentUsers.invalidValues,
          }
        );
      }
      if (
        assignmentUsers.hasMe &&
        (assignmentUsers.userIds.length > 0 || assignmentUsers.userLookups.length > 0)
      ) {
        return toError(
          "INVALID_ASSIGNMENT_USERS",
          'assignment.user_ids cannot mix "me" with user IDs or user names.'
        );
      }
    
      const assignmentUserIds = assignmentUsers.userIds;
      const assignmentUserLookups = assignmentUsers.userLookups;
      const assignmentTargetsMe = assignmentUsers.hasMe || assignment?.executor === "me";
      const resolvedAssignmentExecutor: "me" | "team" = assignmentTargetsMe
        ? "me"
        : (assignment?.executor ?? "team");
      const assignmentTestCaseIds = normalizeNumberIds(assignment?.test_case_ids);
      const assignmentConfigurationIds = normalizeNumberIds(
        assignment?.configuration_ids
      );
      const assignmentSelector = assignment?.selector ?? [];
    
      if (
        assignment?.assignment_method === "manual" &&
        !assignmentTargetsMe &&
        assignmentUserIds.length === 0 &&
        assignmentUserLookups.length === 0
      ) {
        return toMissingInfoError(
          "MISSING_ASSIGNMENT_USERS",
          "Manual assignment requires at least one user_id in assignment.user_ids.",
          ["assignment.user_ids"]
        );
      }
    
      if (
        assignment?.assignment_method === "manual" &&
        assignment.assignment_criteria === "testCase" &&
        assignmentTestCaseIds.length === 0 &&
        assignmentSelector.length === 0
      ) {
        return toMissingInfoError(
          "MISSING_ASSIGNMENT_TEST_CASES",
          "Manual testCase assignment requires assignment.test_case_ids or assignment.selector.",
          ["assignment.test_case_ids", "assignment.selector"]
        );
      }
    
      if (
        assignment?.assignment_method === "manual" &&
        assignment.assignment_criteria === "configuration" &&
        assignmentConfigurationIds.length === 0 &&
        (!configurations || configurations.length === 0)
      ) {
        return toMissingInfoError(
          "MISSING_ASSIGNMENT_CONFIGURATIONS",
          "Manual configuration assignment requires assignment.configuration_ids or configurations.",
          ["assignment.configuration_ids", "configurations"]
        );
      }
    
      const testCaseIds = normalizeNumberIds(test_cases?.test_case_ids);
      const testCaseSelector = test_cases?.selector;
      const hasTestCaseAssignee = test_cases?.assignee !== undefined;
      const testCaseAssigneeValue = normalizeAssignee(test_cases?.assignee);
    
      if (hasTestCaseAssignee && testCaseAssigneeValue === undefined) {
        return toError(
          "INVALID_TEST_CASE_ASSIGNEE",
          'test_cases.assignee must be a user ID, "me", name, username, or email.'
        );
      }
    
      const shouldAddTestCases =
        test_cases !== undefined &&
        (testCaseIds.length > 0 || (testCaseSelector?.length ?? 0) > 0);
      const shouldCreateConfigurations =
        configurations !== undefined && configurations.length > 0;
      const shouldAssignFromTestCaseAssignee =
        assignment === undefined &&
        hasTestCaseAssignee &&
        shouldAddTestCases;
      const shouldAssign = assignment !== undefined || shouldAssignFromTestCaseAssignee;
    
      const steps: Record<string, { endpoint: string; status: string; detail?: string }> = {
        create_test_plan: { endpoint: "/testplans", status: "pending" },
        add_test_cases: {
          endpoint: "/testplantestcases/bulkAdd",
          status: shouldAddTestCases ? "pending" : "skipped",
        },
        add_configurations: {
          endpoint: "/testplanconfigurations",
          status: shouldCreateConfigurations ? "pending" : "skipped",
        },
        assign_test_plan: {
          endpoint: "/testplans/assign",
          status: shouldAssign ? "pending" : "skipped",
        },
      };
    
      let createdPlanId: number | undefined;
      let createdPlanTitle: string | undefined;
    
      try {
        const client = getApiClient();
    
        // Resolve folder (supports ID or title)
        let resolvedFolderId: number | null | undefined;
        if (test_plan_folder !== undefined) {
          if (test_plan_folder === null) {
            resolvedFolderId = null;
          } else {
            const numericFolderId = toNumberId(test_plan_folder);
            if (numericFolderId !== undefined) {
              resolvedFolderId = numericFolderId;
            } else {
              const folderTitle = normalizeString(test_plan_folder);
              if (!folderTitle) {
                return toError(
                  "INVALID_TEST_PLAN_FOLDER",
                  "test_plan_folder must be a numeric ID, non-empty title, or null."
                );
              }
    
              const cachedContext = getCachedProjectContext(resolvedProjectId);
              const cachedFolders = mapTestPlanFoldersForLookup(
                Array.isArray(cachedContext?.test_plan_folders)
                  ? cachedContext.test_plan_folders
                  : []
              );
    
              let matchedFolders = findFoldersByTitle(cachedFolders, folderTitle);
    
              if (matchedFolders.length !== 1) {
                const folders = await client.listTestPlanFolders(resolvedProjectId);
                const liveFolders = mapTestPlanFoldersForLookup(
                  Array.isArray(folders) ? folders : []
                );
                matchedFolders = findFoldersByTitle(liveFolders, folderTitle);
              }
    
              if (matchedFolders.length === 0) {
                return toError(
                  "TEST_PLAN_FOLDER_NOT_FOUND",
                  `Test plan folder not found with title "${folderTitle}" in that project.`
                );
              }
    
              if (matchedFolders.length > 1) {
                const matchingIds = matchedFolders.map((folder) => folder.id);
                return toError(
                  "AMBIGUOUS_TEST_PLAN_FOLDER",
                  `Multiple folders matched "${folderTitle}". Provide folder ID instead.`,
                  { matching_ids: matchingIds }
                );
              }
    
              resolvedFolderId = matchedFolders[0].id;
            }
          }
        }
    
        // Resolve release (supports ID or title)
        let resolvedReleaseId: number | undefined;
        if (release !== undefined) {
          const numericReleaseId = toNumberId(release);
          if (numericReleaseId !== undefined) {
            resolvedReleaseId = numericReleaseId;
          } else {
            const releaseTitle = normalizeString(release);
            if (!releaseTitle) {
              return toError(
                "INVALID_RELEASE",
                "release must be a numeric ID or non-empty title."
              );
            }
    
            const cachedContext = getCachedProjectContext(resolvedProjectId);
            const cachedReleases = mapReleasesForLookup(
              Array.isArray(cachedContext?.releases) ? cachedContext.releases : []
            );
    
            let matchedReleases = findReleasesByTitle(cachedReleases, releaseTitle);
    
            if (matchedReleases.length !== 1) {
              const releases = await client.listReleases(resolvedProjectId);
              const liveReleases = mapReleasesForLookup(
                Array.isArray(releases) ? releases : []
              );
              matchedReleases = findReleasesByTitle(liveReleases, releaseTitle);
            }
    
            if (matchedReleases.length === 0) {
              return toError(
                "RELEASE_NOT_FOUND",
                `Release not found with title "${releaseTitle}" in that project.`
              );
            }
    
            if (matchedReleases.length > 1) {
              const matchingIds = matchedReleases.map((item) => item.id);
              return toError(
                "AMBIGUOUS_RELEASE",
                `Multiple releases matched "${releaseTitle}". Provide release ID instead.`,
                { matching_ids: matchingIds }
              );
            }
    
            resolvedReleaseId = matchedReleases[0].id;
          }
        }
    
        let resolvedAssignmentUserIds = assignmentUserIds;
        let resolvedTestCaseAssignee: ResolvedAssigneeValue | undefined =
          typeof testCaseAssigneeValue === "number" || testCaseAssigneeValue === "me"
            ? testCaseAssigneeValue
            : undefined;
        const testCaseAssigneeLookup =
          typeof testCaseAssigneeValue === "string" && testCaseAssigneeValue !== "me"
            ? testCaseAssigneeValue
            : undefined;
    
        if (assignmentUserLookups.length > 0 || testCaseAssigneeLookup !== undefined) {
          const projectUsersRaw = await client.listProjectUsers(resolvedProjectId);
          const projectUsers = mapProjectUsersForLookup(
            Array.isArray(projectUsersRaw) ? projectUsersRaw : []
          );
    
          const toUserMatchPayload = (user: ProjectUserLookup) => ({
            id: user.id,
            name: user.name,
            ...(user.username ? { username: user.username } : {}),
            ...(user.email ? { email: user.email } : {}),
          });
    
          if (assignmentUserLookups.length > 0) {
            const resolvedLookupIds: number[] = [];
    
            for (const lookup of assignmentUserLookups) {
              const matches = findProjectUserMatches(projectUsers, lookup);
              if (matches.length === 0) {
                return toError(
                  "ASSIGNEE_NOT_FOUND",
                  `No project user matched "${lookup}" for assignment.user_ids.`,
                  {
                    field: "assignment.user_ids",
                    lookup,
                  }
                );
              }
              if (matches.length > 1) {
                return toError(
                  "AMBIGUOUS_ASSIGNEE",
                  `Multiple project users matched "${lookup}" for assignment.user_ids. Use a numeric user ID.`,
                  {
                    field: "assignment.user_ids",
                    lookup,
                    matches: matches.map(toUserMatchPayload),
                  }
                );
              }
              resolvedLookupIds.push(matches[0].id);
            }
    
            resolvedAssignmentUserIds = dedupeNumbers([
              ...assignmentUserIds,
              ...resolvedLookupIds,
            ]);
          }
    
          if (testCaseAssigneeLookup) {
            const matches = findProjectUserMatches(projectUsers, testCaseAssigneeLookup);
            if (matches.length === 0) {
              return toError(
                "ASSIGNEE_NOT_FOUND",
                `No project user matched "${testCaseAssigneeLookup}" for test_cases.assignee.`,
                {
                  field: "test_cases.assignee",
                  lookup: testCaseAssigneeLookup,
                }
              );
            }
            if (matches.length > 1) {
              return toError(
                "AMBIGUOUS_ASSIGNEE",
                `Multiple project users matched "${testCaseAssigneeLookup}" for test_cases.assignee. Use a numeric user ID.`,
                {
                  field: "test_cases.assignee",
                  lookup: testCaseAssigneeLookup,
                  matches: matches.map(toUserMatchPayload),
                }
              );
            }
            resolvedTestCaseAssignee = matches[0].id;
          }
        }
    
        // Resolve custom fields for TestPlan entity
        let resolvedCustomFields:
          | Array<{
              id: number;
              name: string;
              label?: string;
              value: CustomFieldResolvedValue;
              valueLabel?: string | string[];
              color?: string;
            }>
          | undefined;
    
        if (custom_fields && custom_fields.length > 0) {
          const project = await client.getProject(resolvedProjectId);
          const companyId = getCompanyIdFromProject(project);
          const customFieldList = await client.listProjectCustomFields(
            resolvedProjectId,
            companyId,
            "TestPlan"
          );
    
          const definitionsByName = new Map<string, CustomFieldDefinition>();
          const definitionsById = new Map<number, CustomFieldDefinition>();
    
          customFieldList.forEach((field) => {
            const id = toNumberId(getField(field, "id"));
            const name = normalizeString(getField<string>(field, "name"));
            if (id === undefined || !name) {
              return;
            }
    
            const fieldType =
              getField<string>(field, "field_type") ?? getField<string>(field, "type");
            const directOptions = getField<unknown[]>(field, "options");
            const extra = getField<Record<string, unknown>>(field, "extra");
            const extraOptions = extra ? getField<unknown[]>(extra, "options") : undefined;
            const options = buildOptionLookup(directOptions ?? extraOptions ?? []);
    
            const definition: CustomFieldDefinition = {
              id,
              name,
              label: normalizeString(getField<string>(field, "label")),
              fieldType: normalizeString(fieldType),
              options,
            };
            definitionsByName.set(name, definition);
            definitionsById.set(id, definition);
          });
    
          const missingFields: string[] = [];
          resolvedCustomFields = custom_fields
            .map((field) => {
              const fieldId = toNumberId(field.id);
              const byId = fieldId !== undefined ? definitionsById.get(fieldId) : undefined;
              const byName = definitionsByName.get(field.name);
              const definition = byId ?? byName;
    
              if (!definition && fieldId === undefined) {
                missingFields.push(field.name);
                return undefined;
              }
    
              const resolvedId = definition?.id ?? fieldId;
              if (resolvedId === undefined) {
                missingFields.push(field.name);
                return undefined;
              }
    
              let resolvedValue: CustomFieldResolvedValue = field.value;
              let resolvedValueLabel: string | string[] | undefined = field.valueLabel;
    
              if (definition && isDropdownFieldType(definition.fieldType)) {
                if (Array.isArray(field.value)) {
                  throw new Error(
                    `Custom field "${definition.name}" expects a single value, not an array.`
                  );
                }
    
                if (isNonNumericString(field.value)) {
                  const matchedOption = findOptionByLabel(definition.options, field.value);
                  if (!matchedOption) {
                    throw new Error(
                      `Custom field option "${field.value}" not found for "${definition.name}".`
                    );
                  }
                  resolvedValue = matchedOption.id;
                  resolvedValueLabel =
                    typeof field.valueLabel === "string" && field.valueLabel.trim().length > 0
                      ? field.valueLabel
                      : matchedOption.label;
                }
              }
    
              if (definition && isMultiSelectFieldType(definition.fieldType)) {
                const inputValues = Array.isArray(field.value) ? field.value : [field.value];
                const outputValues: Array<string | number> = [];
                const outputLabels: string[] = [];
    
                inputValues.forEach((inputValue) => {
                  const numeric = toNumberId(inputValue);
                  if (numeric !== undefined) {
                    outputValues.push(numeric);
                    return;
                  }
                  if (isNonNumericString(inputValue)) {
                    const matchedOption = findOptionByLabel(
                      definition.options,
                      inputValue
                    );
                    if (!matchedOption) {
                      throw new Error(
                        `Custom field option "${inputValue}" not found for "${definition.name}".`
                      );
                    }
                    outputValues.push(matchedOption.id);
                    outputLabels.push(matchedOption.label);
                  }
                });
    
                resolvedValue = outputValues;
                if (Array.isArray(field.valueLabel)) {
                  resolvedValueLabel = field.valueLabel;
                } else if (outputLabels.length > 0) {
                  resolvedValueLabel = outputLabels;
                }
              }
    
              return {
                id: resolvedId,
                name: definition?.name ?? field.name,
                ...(field.label !== undefined
                  ? { label: field.label }
                  : definition?.label
                    ? { label: definition.label }
                    : {}),
                value: resolvedValue,
                ...(resolvedValueLabel !== undefined
                  ? { valueLabel: resolvedValueLabel }
                  : {}),
                ...(field.color !== undefined ? { color: field.color } : {}),
              };
            })
            .filter(
              (
                field
              ): field is {
                id: number;
                name: string;
                label?: string;
                value: CustomFieldResolvedValue;
                valueLabel?: string | string[];
                color?: string;
              } => field !== undefined
            );
    
          if (missingFields.length > 0) {
            return toError(
              "CUSTOM_FIELD_NOT_FOUND",
              `Custom field(s) not found: ${Array.from(new Set(missingFields)).join(", ")}`
            );
          }
        }
    
        // Step 1: Create test plan
        steps.create_test_plan.status = "in_progress";
        const createResult = await client.createTestPlan({
          projectId: resolvedProjectId,
          title: normalizedTitle,
          description,
          priority,
          testPlanFolderId: resolvedFolderId,
          release: resolvedReleaseId,
          startDate: start_date,
          endDate: end_date,
          customFields: resolvedCustomFields,
        });
    
        const createFailure = apiFailureMessage(createResult);
        if (createFailure) {
          steps.create_test_plan.status = "failed";
          steps.create_test_plan.detail = createFailure;
          return toToolResponse(
            {
              error: {
                code: "CREATE_TEST_PLAN_FAILED",
                message: createFailure,
                step: "create_test_plan",
              },
              steps,
            },
            true
          );
        }
    
        createdPlanId = extractId(createResult);
        createdPlanTitle =
          normalizeString(getField<string>(createResult, "title")) ?? normalizedTitle;
    
        if (createdPlanId === undefined) {
          steps.create_test_plan.status = "failed";
          steps.create_test_plan.detail = "Create response did not include test plan ID.";
          return toToolResponse(
            {
              error: {
                code: "INVALID_CREATE_TEST_PLAN_RESPONSE",
                message: "Create response did not include test plan ID.",
                step: "create_test_plan",
              },
              steps,
            },
            true
          );
        }
    
        steps.create_test_plan.status = "completed";
    
        // Step 2: Bulk add test cases
        let bulkAddResult: Record<string, unknown> | undefined;
        if (shouldAddTestCases) {
          steps.add_test_cases.status = "in_progress";
    
          bulkAddResult = await client.bulkAddTestPlanTestCases({
            testplan: createdPlanId,
            testCaseCollection: toSelectorCollection(testCaseIds, testCaseSelector),
            ...(resolvedTestCaseAssignee !== undefined
              ? { assignee: resolvedTestCaseAssignee }
              : {}),
          });
    
          const bulkAddFailure = apiFailureMessage(bulkAddResult);
          if (bulkAddFailure) {
            steps.add_test_cases.status = "failed";
            steps.add_test_cases.detail = bulkAddFailure;
            return toToolResponse(
              {
                error: {
                  code: "ADD_TEST_CASES_FAILED",
                  message: bulkAddFailure,
                  step: "add_test_cases",
                },
                testPlan: {
                  id: createdPlanId,
                  title: createdPlanTitle,
                  project_id: resolvedProjectId,
                },
                steps,
              },
              true
            );
          }
    
          steps.add_test_cases.status = "completed";
        }
    
        // Step 3: Create configurations
        let createConfigurationsResult:
          | Array<Record<string, unknown>>
          | Record<string, unknown>
          | undefined;
        let createdConfigurationIds: number[] = [];
    
        if (shouldCreateConfigurations) {
          steps.add_configurations.status = "in_progress";
    
          createConfigurationsResult = await client.createTestPlanConfigurations({
            projectId: resolvedProjectId,
            testplan: createdPlanId,
            parameters: (configurations ?? []).map((row) =>
              row.map((entry) => ({
                ...(entry.id !== undefined ? { id: entry.id } : {}),
                field: entry.field,
                value: entry.value,
              }))
            ),
          });
    
          const createConfigurationsFailure = apiFailureMessage(createConfigurationsResult);
          if (createConfigurationsFailure) {
            steps.add_configurations.status = "failed";
            steps.add_configurations.detail = createConfigurationsFailure;
            return toToolResponse(
              {
                error: {
                  code: "ADD_CONFIGURATIONS_FAILED",
                  message: createConfigurationsFailure,
                  step: "add_configurations",
                },
                testPlan: {
                  id: createdPlanId,
                  title: createdPlanTitle,
                  project_id: resolvedProjectId,
                },
                steps,
              },
              true
            );
          }
    
          createdConfigurationIds = extractIds(createConfigurationsResult);
          steps.add_configurations.status = "completed";
        }
    
        // Step 4: Assignment
        let assignResult: Record<string, unknown> | undefined;
        if (shouldAssign) {
          steps.assign_test_plan.status = "in_progress";
    
          const assignFromTestCaseAssignee =
            assignment === undefined &&
            resolvedTestCaseAssignee !== undefined &&
            shouldAddTestCases;
          const assignmentExecutorForPayload: "me" | "team" = assignFromTestCaseAssignee
            ? resolvedTestCaseAssignee === "me"
              ? "me"
              : "team"
            : resolvedAssignmentExecutor;
          const assignmentCriteriaForPayload: "testCase" | "configuration" =
            assignment?.assignment_criteria ?? "testCase";
          const assignmentMethodForPayload: "automatic" | "manual" =
            assignment?.assignment_method ?? "automatic";
    
          const resolvedAssignmentConfigIds =
            assignmentCriteriaForPayload === "configuration"
              ? assignmentConfigurationIds.length > 0
                ? assignmentConfigurationIds
                : createdConfigurationIds
              : [];
    
          if (
            assignmentMethodForPayload === "manual" &&
            assignmentCriteriaForPayload === "configuration" &&
            resolvedAssignmentConfigIds.length === 0
          ) {
            steps.assign_test_plan.status = "failed";
            steps.assign_test_plan.detail =
              "No configuration IDs available for manual configuration assignment.";
            return toToolResponse(
              {
                error: {
                  code: "MISSING_ASSIGNMENT_CONFIGURATIONS",
                  message:
                    "No configuration IDs available for manual configuration assignment.",
                  step: "assign_test_plan",
                  details: {
                    missing_fields: ["assignment.configuration_ids", "configurations"],
                    follow_up_questions: [
                      missingInfoQuestionByField["assignment.configuration_ids"],
                      missingInfoQuestionByField["configurations"],
                    ],
                  },
                },
                testPlan: {
                  id: createdPlanId,
                  title: createdPlanTitle,
                  project_id: resolvedProjectId,
                },
                steps,
              },
              true
            );
          }
    
          const assignmentUsersForPayload: Array<number | "me"> =
            assignFromTestCaseAssignee && resolvedTestCaseAssignee !== undefined
              ? [resolvedTestCaseAssignee]
              : assignmentExecutorForPayload === "me"
                ? ["me"]
                : resolvedAssignmentUserIds;
    
          assignResult = await client.assignTestPlan({
            projectId: resolvedProjectId,
            testplan: createdPlanId,
            executor: assignmentExecutorForPayload,
            assignmentCriteria: assignmentCriteriaForPayload,
            assignmentMethod: assignmentMethodForPayload,
            assignment: {
              user: assignmentUsersForPayload,
              testCases: assignFromTestCaseAssignee
                ? toSelectorCollection(testCaseIds, testCaseSelector)
                : toSelectorCollection(assignmentTestCaseIds, assignmentSelector),
              configuration:
                assignmentCriteriaForPayload === "configuration"
                  ? resolvedAssignmentConfigIds
                  : null,
            },
          });
    
          const assignFailure = apiFailureMessage(assignResult);
          if (assignFailure) {
            steps.assign_test_plan.status = "failed";
            steps.assign_test_plan.detail = assignFailure;
            return toToolResponse(
              {
                error: {
                  code: "ASSIGN_TEST_PLAN_FAILED",
                  message: assignFailure,
                  step: "assign_test_plan",
                },
                testPlan: {
                  id: createdPlanId,
                  title: createdPlanTitle,
                  project_id: resolvedProjectId,
                },
                steps,
              },
              true
            );
          }
    
          steps.assign_test_plan.status = "completed";
        }
    
        return toToolResponse(
          {
            success: true,
            message: "Test plan created successfully",
            testPlan: {
              id: createdPlanId,
              title: createdPlanTitle,
              project_id: resolvedProjectId,
            },
            steps,
            results: {
              ...(bulkAddResult !== undefined ? { add_test_cases: bulkAddResult } : {}),
              ...(createConfigurationsResult !== undefined
                ? { add_configurations: createConfigurationsResult }
                : {}),
              ...(assignResult !== undefined ? { assign_test_plan: assignResult } : {}),
            },
          },
          true
        );
      } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        return toToolResponse(
          {
            error: {
              code: "API_ERROR",
              message,
            },
            ...(createdPlanId !== undefined
              ? {
                  testPlan: {
                    id: createdPlanId,
                    title: createdPlanTitle ?? normalizedTitle,
                    project_id: resolvedProjectId,
                  },
                }
              : {}),
            steps,
          },
          true
        );
      }
    }
  • The Zod schema definition for input validation of the "create_test_plan" tool.
    export const createTestPlanSchema = z.object({
      project_id: z
        .number()
        .optional()
        .describe("Project ID (optional if TC_DEFAULT_PROJECT is set)"),
      title: z
        .string()
        .optional()
        .describe(
          'Test plan title (optional; defaults to "Test Plan DD Month YYYY HH:mm:ss")'
        ),
      description: z.string().optional().describe("Test plan description (HTML supported)"),
      priority: z
        .number()
        .min(0)
        .max(2)
        .optional()
        .describe("Priority: 0=Low, 1=Normal, 2=High"),
      test_plan_folder: z
        .union([z.number(), z.string(), z.null()])
        .optional()
        .describe("Test plan folder ID or title (null to place at root)"),
      release: z
        .union([z.number(), z.string()])
        .optional()
        .describe("Release ID or title"),
      start_date: z
        .string()
        .optional()
        .describe("Planned start date (YYYY-MM-DD)"),
      end_date: z
        .string()
        .optional()
        .describe("Planned end date (YYYY-MM-DD)"),
      custom_fields: z
        .array(customFieldSchema)
        .optional()
        .describe("Array of test plan custom field values"),
      test_cases: testCasesSchema
        .optional()
        .describe("Test cases to bulk-add immediately after plan creation"),
      configurations: z
        .array(z.array(configurationPairSchema))
        .optional()
        .describe("Configuration matrix to attach to the test plan"),
      assignment: assignmentSchema
        .optional()
        .describe("Assignment payload to execute after creation"),
    });
  • Tool registration where "create_test_plan" is linked to its handler.
    createTestPlanTool.name,
    createTestPlanTool.description,
    createTestPlanSchema.shape,
    async (args) => {
      return handleCreateTestPlan(args);
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden and succeeds in disclosing the multi-step execution flow (4 internal API calls), default behaviors (title auto-generation), and special value handling ('me' for self-assignment). Minor gap: no mention of error handling, rate limits, or return value structure.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Well-structured with clear sections (purpose, pre-conditions, execution flow, options, example). Despite length, every section serves a purpose given the tool's complexity. Slightly redundant to list optional parameters when schema is comprehensive, but acts as useful quick-reference for nested objects.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (12 parameters, nested objects, 4 internal API calls) and absence of an output schema, the description adequately covers inputs but fails to describe what the tool returns (ID, object, success boolean?), leaving a critical gap for an agent handling the response.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

While the schema has 100% coverage (baseline 3), the description adds significant value through the concrete JSON example showing parameter interaction, clarifies the title default format syntax, and explicitly maps user utterances ('assign to me') to parameter values.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description opens with a specific verb ('Create') and resource ('test plan'), explicitly identifies the target system ('TestCollab'), and clearly distinguishes from siblings like create_suite or create_test_case by focusing on the test plan entity.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit pre-conditions ('Before calling this tool: Ask follow-up questions... Do not infer or auto-generate required values') that constrain when the tool should be invoked. However, it does not explicitly contrast with sibling tools like update_test_plan to clarify when creation vs. update is appropriate.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/TCSoftInc/testcollab-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server