Skip to main content
Glama
TCSoftInc

TestCollab MCP Server

by TCSoftInc

update_test_plan

Modify existing test plans by updating fields like title, priority, status, dates, assignments, and custom fields to reflect changes in testing requirements.

Instructions

Update an existing test plan in TestCollab.

Required:

  • id (test plan ID)

All other fields are optional and only provided fields are updated.

Fields:

  • title

  • description (null to clear)

  • priority: 0/1/2 or low/normal/high

  • status: 0/1/2/3 or draft/ready/finished/finished_with_failures

  • test_plan_folder: ID/title/null

  • release: ID/title/null

  • start_date, end_date (null to clear)

  • archived

  • custom_fields (null/[] to clear)

  • assignee (single-user convenience)

  • assignment (advanced assignment payload)

Example: { "id": 812, "title": "Release 3.0 Regression", "status": "ready", "test_plan_folder": "Mobile", "assignee": "me" }

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
idYesTest plan ID to update (required)
project_idNoProject ID (optional if TC_DEFAULT_PROJECT is set)
titleNoNew test plan title
descriptionNoNew test plan description (HTML supported, null to clear)
priorityNoPriority: 0/1/2 or "low"/"normal"/"high"
statusNoStatus: 0/1/2/3 or "draft"/"ready"/"finished"/"finished_with_failures"
test_plan_folderNoTest plan folder ID or title (null to place at root)
releaseNoRelease ID or title (null to clear)
start_dateNoPlanned start date (YYYY-MM-DD, null to clear)
end_dateNoPlanned end date (YYYY-MM-DD, null to clear)
archivedNoArchive/unarchive this test plan
custom_fieldsNoArray of test plan custom field values (null/[] to clear)
assigneeNoConvenience field to assign plan to one user (user ID, "me", name, username, or email)
assignmentNoAssignment payload to execute after update

Implementation Reference

  • The `handleUpdateTestPlan` function serves as the primary handler for the `update_test_plan` tool. It validates the input against `updateTestPlanSchema`, processes both metadata and assignment updates, and interfaces with the TestCollab API client to perform the updates.
    export async function handleUpdateTestPlan(args: unknown): Promise<ToolResponse> {
      const parsed = updateTestPlanSchema.safeParse(args);
      if (!parsed.success) {
        return toError("VALIDATION_ERROR", "Invalid input parameters", parsed.error.errors);
      }
    
      const {
        id,
        project_id,
        title,
        description,
        priority,
        status,
        test_plan_folder,
        release,
        start_date,
        end_date,
        archived,
        custom_fields,
        assignee,
        assignment,
      } = parsed.data;
    
      const rawArgs = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
      const hasField = (key: string) => Object.prototype.hasOwnProperty.call(rawArgs, key);
    
      const metadataFieldNames = [
        "title",
        "description",
        "priority",
        "status",
        "test_plan_folder",
        "release",
        "start_date",
        "end_date",
        "archived",
        "custom_fields",
      ] as const;
      const assignmentFieldNames = ["assignee", "assignment"] as const;
      const hasMetadataUpdate = metadataFieldNames.some((field) => hasField(field));
      const hasAssignmentUpdate = assignmentFieldNames.some((field) => hasField(field));
      const userSuppliedUpdatableFields = [
        ...metadataFieldNames,
        ...assignmentFieldNames,
      ].filter((field) => hasField(field));
    
      if (hasField("assignee") && hasField("assignment")) {
        return toError(
          "INVALID_INPUT",
          "Provide either assignee or assignment, not both."
        );
      }
    
      if (!hasMetadataUpdate && !hasAssignmentUpdate) {
        return toError(
          "INVALID_INPUT",
          "No updatable fields provided. Supply at least one metadata or assignment field to update."
        );
      }
    
      const requestContext = getRequestContext();
      const envConfig = requestContext ? null : getConfig();
      const resolvedProjectId =
        project_id ?? requestContext?.defaultProjectId ?? envConfig?.defaultProjectId;
    
      if (!resolvedProjectId) {
        return toError(
          "MISSING_PROJECT_ID",
          "project_id is required. Either provide it in the request or set TC_DEFAULT_PROJECT."
        );
      }
    
      try {
        const client = getApiClient();
    
        let updatedId = id;
        let updatedTitle: string | undefined;
    
        if (hasMetadataUpdate) {
          const existingRaw = await client.getTestPlanRaw(id);
          const existing = unwrapApiEntity(existingRaw);
    
          if (!existing) {
            return toError(
              "INVALID_TEST_PLAN",
              `Unable to load test plan ${id} for update.`
            );
          }
    
          const existingTitle = normalizeString(getField<string>(existing, "title"));
          const existingPriority = toNumberId(getField(existing, "priority"));
          const existingStatus = toNumberId(getField(existing, "status"));
          const existingFolderRaw =
            getField(existing, "test_plan_folder") ?? getField(existing, "testPlanFolder");
          const existingFolderId =
            existingFolderRaw === null
              ? null
              : extractId(existingFolderRaw) ?? toNumberId(existingFolderRaw);
          const existingReleaseRaw = getField(existing, "release");
          const existingReleaseId =
            existingReleaseRaw === null
              ? null
              : extractId(existingReleaseRaw) ?? toNumberId(existingReleaseRaw);
    
          const resolvedTitle = title ?? existingTitle;
          const resolvedPriority = toPriorityCode(priority) ?? existingPriority;
          const resolvedStatus = toStatusCode(status) ?? existingStatus;
    
          if (!resolvedTitle || resolvedPriority === undefined || resolvedStatus === undefined) {
            return toError(
              "INVALID_EXISTING_TEST_PLAN",
              "Unable to resolve required test plan fields (title, priority, status) for update.",
              {
                missing: [
                  ...(!resolvedTitle ? ["title"] : []),
                  ...(resolvedPriority === undefined ? ["priority"] : []),
                  ...(resolvedStatus === undefined ? ["status"] : []),
                ],
              }
            );
          }
    
          let resolvedFolderId: number | null = existingFolderId ?? null;
          if (hasField("test_plan_folder")) {
            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;
              }
            }
          }
    
          let resolvedReleaseId: number | null = existingReleaseId ?? null;
          if (hasField("release")) {
            if (release === null) {
              resolvedReleaseId = null;
            } else {
              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, non-empty title, or null."
                  );
                }
    
                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 resolvedCustomFields:
            | Array<{
                id: number;
                name: string;
                label?: string;
                value: CustomFieldResolvedValue;
                valueLabel?: string | string[];
                color?: string;
              }>
            | undefined;
    
          if (hasField("custom_fields")) {
            const customFieldsInput = custom_fields;
            if (!customFieldsInput || customFieldsInput.length === 0) {
              resolvedCustomFields = [];
            } else {
              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 fieldId = toNumberId(getField(field, "id"));
                const name = normalizeString(getField<string>(field, "name"));
                if (fieldId === 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: fieldId,
                  name,
                  label: normalizeString(getField<string>(field, "label")),
                  fieldType: normalizeString(fieldType),
                  options,
                };
                definitionsByName.set(name, definition);
                definitionsById.set(fieldId, definition);
              });
    
              const missingFields: string[] = [];
              resolvedCustomFields = customFieldsInput
                .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(", ")}`
                );
              }
            }
          }
    
          const updateResult = await client.updateTestPlan(id, {
            projectId: resolvedProjectId,
            title: resolvedTitle,
            priority: resolvedPriority,
            status: resolvedStatus,
            testPlanFolderId: resolvedFolderId,
            release: resolvedReleaseId,
            ...(hasField("description") ? { description: description ?? null } : {}),
            ...(hasField("start_date") ? { startDate: start_date ?? null } : {}),
            ...(hasField("end_date") ? { endDate: end_date ?? null } : {}),
            ...(hasField("archived") ? { archived } : {}),
            ...(hasField("custom_fields")
              ? { customFields: resolvedCustomFields ?? [] }
              : {}),
          });
    
          const updateFailure = apiFailureMessage(updateResult);
          if (updateFailure) {
            return toToolResponse(
              {
                error: {
                  code: "UPDATE_TEST_PLAN_FAILED",
                  message: updateFailure,
                },
              },
              true
            );
          }
    
          updatedId = extractId(updateResult) ?? id;
          updatedTitle =
            normalizeString(getField<string>(updateResult, "title")) ?? resolvedTitle;
        }
    
        let assignResult: Record<string, unknown> | undefined;
        if (hasAssignmentUpdate) {
          const assigneeInputProvided = hasField("assignee");
          const assignmentInputProvided = hasField("assignment");
    
          if (assignmentInputProvided && !assignment) {
            return toError(
              "INVALID_INPUT",
              "assignment must be an object when provided."
            );
          }
    
          const normalizedAssignee = assigneeInputProvided
            ? normalizeAssignee(assignee)
            : undefined;
    
          if (assigneeInputProvided && normalizedAssignee === undefined) {
            return toError(
              "INVALID_ASSIGNEE",
              'assignee must be a user ID, "me", name, username, or email.'
            );
          }
    
          const assignmentInput = assignmentInputProvided
            ? assignment
            : {
                executor:
                  normalizedAssignee === "me" ? ("me" as const) : ("team" as const),
                assignment_criteria: "testCase" as const,
                assignment_method: "automatic" as const,
                user_ids:
                  normalizedAssignee !== undefined
                    ? [normalizedAssignee]
                    : undefined,
                test_case_ids: [] as Array<number | string>,
                selector: [] as TestCaseSelectorQuery[],
                configuration_ids: [] as Array<number | string>,
              };
    
          const assignmentUsers = normalizeAssignmentUsers(assignmentInput?.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 assignmentTargetsMe =
            assignmentUsers.hasMe || assignmentInput?.executor === "me";
          const resolvedAssignmentExecutor: "me" | "team" = assignmentTargetsMe
            ? "me"
            : (assignmentInput?.executor ?? "team");
          const assignmentCriteria: "testCase" | "configuration" =
            assignmentInput?.assignment_criteria ?? "testCase";
          const assignmentMethod: "automatic" | "manual" =
            assignmentInput?.assignment_method ?? "automatic";
          const assignmentTestCaseIds = normalizeNumberIds(
            assignmentInput?.test_case_ids
          );
          const assignmentConfigurationIds = normalizeNumberIds(
            assignmentInput?.configuration_ids
          );
          const assignmentSelector = assignmentInput?.selector ?? [];
    
          if (
            assignmentMethod === "manual" &&
            !assignmentTargetsMe &&
            assignmentUsers.userIds.length === 0 &&
            assignmentUsers.userLookups.length === 0
          ) {
            return toError(
              "MISSING_ASSIGNMENT_USERS",
              "Manual assignment requires at least one user_id in assignment.user_ids."
            );
          }
    
          if (
            assignmentMethod === "manual" &&
            assignmentCriteria === "testCase" &&
            assignmentTestCaseIds.length === 0 &&
            assignmentSelector.length === 0
          ) {
            return toError(
              "MISSING_ASSIGNMENT_TEST_CASES",
              "Manual testCase assignment requires assignment.test_case_ids or assignment.selector."
            );
          }
    
          if (
            assignmentMethod === "manual" &&
            assignmentCriteria === "configuration" &&
            assignmentConfigurationIds.length === 0
          ) {
            return toError(
              "MISSING_ASSIGNMENT_CONFIGURATIONS",
              "Manual configuration assignment requires assignment.configuration_ids."
            );
          }
    
          let resolvedAssignmentUserIds = assignmentUsers.userIds;
          if (assignmentUsers.userLookups.length > 0) {
            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 } : {}),
            });
    
            const resolvedLookupIds: number[] = [];
            for (const lookup of assignmentUsers.userLookups) {
              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([
              ...resolvedAssignmentUserIds,
              ...resolvedLookupIds,
            ]);
          }
    
          const assignmentUsersForPayload: Array<number | "me"> =
            resolvedAssignmentExecutor === "me"
              ? ["me"]
              : resolvedAssignmentUserIds;
    
          let assignUsedFallback = false;
          try {
            assignResult = await client.assignTestPlan({
              projectId: resolvedProjectId,
              testplan: updatedId,
              executor: resolvedAssignmentExecutor,
              assignmentCriteria,
              assignmentMethod,
              assignment: {
                user: assignmentUsersForPayload,
                testCases: toSelectorCollection(assignmentTestCaseIds, assignmentSelector),
                configuration:
                  assignmentCriteria === "configuration"
                    ? assignmentConfigurationIds
                    : null,
              },
            });
          } catch (assignError) {
            const assignErrorMessage = toErrorMessage(assignError);
            const shouldFallbackToPlanAssignees =
              assignmentMethod === "automatic" &&
              resolvedAssignmentExecutor === "team" &&
              isNoAssignableItemsError(assignErrorMessage);
    
            if (!shouldFallbackToPlanAssignees) {
              throw assignError;
            }
    
            let fallbackAssigneeIds = assignmentUsersForPayload.filter(
              (user): user is number => typeof user === "number"
            );
    
            if (fallbackAssigneeIds.length === 0) {
              const fallbackUsersRaw = await client.listProjectUsers(resolvedProjectId);
              const fallbackUsers = mapProjectUsersForLookup(
                Array.isArray(fallbackUsersRaw) ? fallbackUsersRaw : []
              );
              fallbackAssigneeIds = fallbackUsers.map((user) => user.id);
            }
    
            fallbackAssigneeIds = dedupeNumbers(fallbackAssigneeIds);
    
            if (fallbackAssigneeIds.length === 0) {
              return toToolResponse(
                {
                  error: {
                    code: "ASSIGN_TEST_PLAN_FAILED",
                    message: assignErrorMessage,
                  },
                  ...(hasMetadataUpdate
                    ? {
                        testPlan: {
                          id: updatedId,
                          ...(updatedTitle ? { title: updatedTitle } : {}),
                          project_id: resolvedProjectId,
                        },
                      }
                    : {}),
                },
                true
              );
            }
    
            const fallbackExistingRaw = await client.getTestPlanRaw(updatedId);
            const fallbackExisting = unwrapApiEntity(fallbackExistingRaw);
    
            if (!fallbackExisting) {
              return toError(
                "INVALID_TEST_PLAN",
                `Unable to load test plan ${updatedId} for fallback assignment update.`
              );
            }
    
            const fallbackTitle = normalizeString(getField<string>(fallbackExisting, "title"));
            const fallbackPriority = toNumberId(getField(fallbackExisting, "priority"));
            const fallbackStatus = toNumberId(getField(fallbackExisting, "status"));
            const fallbackFolderRaw =
              getField(fallbackExisting, "test_plan_folder") ??
              getField(fallbackExisting, "testPlanFolder");
            const fallbackFolderId =
              fallbackFolderRaw === null
                ? null
                : extractId(fallbackFolderRaw) ?? toNumberId(fallbackFolderRaw);
            const fallbackReleaseRaw = getField(fallbackExisting, "release");
            const fallbackReleaseId =
              fallbackReleaseRaw === null
                ? null
                : extractId(fallbackReleaseRaw) ?? toNumberId(fallbackReleaseRaw);
    
            if (!fallbackTitle || fallbackPriority === undefined || fallbackStatus === undefined) {
              return toError(
                "INVALID_EXISTING_TEST_PLAN",
                "Unable to resolve required test plan fields (title, priority, status) for fallback assignment update.",
                {
                  missing: [
                    ...(!fallbackTitle ? ["title"] : []),
                    ...(fallbackPriority === undefined ? ["priority"] : []),
                    ...(fallbackStatus === undefined ? ["status"] : []),
                  ],
                }
              );
            }
    
            const fallbackUpdateResult = await client.updateTestPlan(updatedId, {
              projectId: resolvedProjectId,
              title: fallbackTitle,
              priority: fallbackPriority,
              status: fallbackStatus,
              testPlanFolderId: fallbackFolderId ?? null,
              ...(fallbackReleaseId !== undefined ? { release: fallbackReleaseId } : {}),
              assignmentMethod,
              assignmentCriteria,
              assignedTo: fallbackAssigneeIds,
            });
    
            const fallbackUpdateFailure = apiFailureMessage(fallbackUpdateResult);
            if (fallbackUpdateFailure) {
              return toToolResponse(
                {
                  error: {
                    code: "ASSIGN_TEST_PLAN_FAILED",
                    message: fallbackUpdateFailure,
                  },
                  ...(hasMetadataUpdate
                    ? {
                        testPlan: {
                          id: updatedId,
                          ...(updatedTitle ? { title: updatedTitle } : {}),
                          project_id: resolvedProjectId,
                        },
                      }
                    : {}),
                },
                true
              );
            }
    
            assignUsedFallback = true;
            assignResult = {
              status: true,
              fallback_assignment: true,
              assign_error: assignErrorMessage,
              assigned_to: fallbackAssigneeIds,
            };
          }
    
          if (!assignUsedFallback) {
            const assignFailure = apiFailureMessage(assignResult);
            if (assignFailure) {
              return toToolResponse(
                {
                  error: {
                    code: "ASSIGN_TEST_PLAN_FAILED",
                    message: assignFailure,
                  },
                  ...(hasMetadataUpdate
                    ? {
                        testPlan: {
                          id: updatedId,
                          ...(updatedTitle ? { title: updatedTitle } : {}),
                          project_id: resolvedProjectId,
                        },
                      }
                    : {}),
                },
                true
              );
            }
          }
        }
    
        const message = hasMetadataUpdate
          ? hasAssignmentUpdate
            ? "Test plan updated successfully and assignment applied."
            : "Test plan updated successfully"
          : "Test plan assignment updated successfully";
    
        return toToolResponse(
          {
            success: true,
            message,
            testPlan: {
              id: updatedId,
              ...(updatedTitle ? { title: updatedTitle } : {}),
              project_id: resolvedProjectId,
            },
            updatedFields: userSuppliedUpdatableFields,
            ...(assignResult !== undefined
              ? { results: { assign_test_plan: assignResult } }
              : {}),
          },
          true
        );
      } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        return toToolResponse(
          {
            error: {
              code: "API_ERROR",
              message,
            },
          },
          true
        );
      }
    }
  • `updateTestPlanSchema` defines the input structure and validation for the `update_test_plan` tool using Zod. It enforces required fields (like `id`) and optional fields for various test plan attributes.
    export const updateTestPlanSchema = z.object({
      id: z.number().describe("Test plan ID to update (required)"),
      project_id: z
        .number()
        .optional()
        .describe("Project ID (optional if TC_DEFAULT_PROJECT is set)"),
      title: z.string().min(1).optional().describe("New test plan title"),
      description: z
        .string()
        .nullable()
        .optional()
        .describe("New test plan description (HTML supported, null to clear)"),
      priority: priorityInputSchema
        .optional()
        .describe('Priority: 0/1/2 or "low"/"normal"/"high"'),
      status: statusInputSchema
        .optional()
        .describe(
          'Status: 0/1/2/3 or "draft"/"ready"/"finished"/"finished_with_failures"'
        ),
      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(), z.null()])
        .optional()
        .describe("Release ID or title (null to clear)"),
      start_date: z
        .string()
        .nullable()
        .optional()
        .describe("Planned start date (YYYY-MM-DD, null to clear)"),
      end_date: z
        .string()
        .nullable()
        .optional()
        .describe("Planned end date (YYYY-MM-DD, null to clear)"),
      archived: z.boolean().optional().describe("Archive/unarchive this test plan"),
      custom_fields: z
        .array(customFieldSchema)
        .nullable()
        .optional()
        .describe("Array of test plan custom field values (null/[] to clear)"),
      assignee: z
        .union([z.number(), z.string()])
        .optional()
        .describe(
          'Convenience field to assign plan to one user (user ID, "me", name, username, or email)'
        ),
      assignment: assignmentSchema
        .optional()
        .describe("Assignment payload to execute after update"),
    });
  • `updateTestPlanTool` provides the MCP registration object for `update_test_plan`, including its name, description, and the JSON schema definition for its inputs.
    export const updateTestPlanTool = {
      name: "update_test_plan",
      description: `Update an existing test plan in TestCollab.
    
    Required:
    - id (test plan ID)
    
    All other fields are optional and only provided fields are updated.
    
    Fields:
    - title
    - description (null to clear)
    - priority: 0/1/2 or low/normal/high
    - status: 0/1/2/3 or draft/ready/finished/finished_with_failures
    - test_plan_folder: ID/title/null
    - release: ID/title/null
    - start_date, end_date (null to clear)
    - archived
    - custom_fields (null/[] to clear)
    - assignee (single-user convenience)
    - assignment (advanced assignment payload)
    
    Example:
    {
      "id": 812,
      "title": "Release 3.0 Regression",
      "status": "ready",
      "test_plan_folder": "Mobile",
      "assignee": "me"
    }`,
    
      inputSchema: {

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