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: {
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 well: it explains the PATCH-like partial update behavior, documents that 'null clears fields' for multiple parameters, and distinguishes between 'assignee' (convenience) and 'assignment' (advanced) semantics. It does not describe the return value or error conditions.

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?

The description is well-structured with clear sections (summary, required fields, optional fields list, example). Despite length due to 14 parameters, every section earns its place—the example JSON is particularly valuable. The key purpose statement is front-loaded in the first sentence.

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

Completeness4/5

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

Given the high complexity (14 parameters, nested objects) and lack of output schema, the description comprehensively covers input semantics and provides a working example. Minor gap: it does not describe what the tool returns on success, which would be helpful since no output schema exists to provide that signal.

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 by grouping parameters logically, explicitly repeating the 'null to clear' pattern across multiple fields, and clarifying the distinction between single-user 'assignee' and complex 'assignment' payloads.

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 'Update an existing test plan in TestCollab,' providing a specific verb (Update), resource (test plan), and system context (TestCollab). The word 'existing' clearly distinguishes this from the sibling create_test_plan tool.

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

Usage Guidelines3/5

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

The description explains critical usage semantics—'only provided fields are updated'—clarifying the partial update behavior. However, it lacks explicit guidance on when to use this versus siblings (e.g., delete_test_plan for removal) or prerequisites (e.g., retrieving the plan first).

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