Skip to main content
Glama
TCSoftInc

TestCollab MCP Server

by TCSoftInc

update_test_case

Modify existing test cases in TestCollab by updating fields like title, priority, steps, tags, or custom fields to maintain accurate testing documentation.

Instructions

Update an existing test case in TestCollab. Only provided fields will be updated. Tip: Call get_project_context first to resolve suite/tag/custom field names to IDs. Tip: If you need existing steps (e.g., to fill missing expected results), call get_test_case first and then use steps_patch.

Required: id (test case ID)

Optional fields:

  • title: New title

  • suite: Move to different suite

  • description: New description (HTML)

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

  • steps: Replaces all existing steps

  • steps_patch: Patch steps by step number (1-based) without replacing all steps

  • tags: Replaces all existing tags

  • requirements: Replaces all existing requirements

  • custom_fields: Update specific custom fields

Example: { "id": 1712, "title": "Updated login test", "priority": 2 }

Example - patch a single step: { "id": 1714, "steps_patch": [ { "step_number": 1, "expected_result": "Appropriate expected result" } ] }

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
idYesTest case ID to update (required)
project_idNoProject ID (optional if default is set)
titleNoNew test case title
suiteNoMove to a different suite by ID or title (null to remove)
descriptionNoNew description (HTML supported)
priorityNoNew priority: 0=Low, 1=Normal, 2=High
stepsNoReplace all steps
steps_patchNoPatch steps by step number (1-based) without replacing all steps
tagsNoReplace tags with these IDs or names
requirementsNoReplace requirements with these IDs or names
custom_fieldsNoUpdate custom field values (id optional if name provided)
attachmentsNoReplace attachments with these file IDs

Implementation Reference

  • The handleUpdateTestCase function handles the execution logic for the update_test_case tool, including input validation, project/suite resolution, fetching current state to construct a complete update payload, and calling the API client.
    export async function handleUpdateTestCase(
      args: unknown
    ): Promise<{ content: Array<{ type: "text"; text: string }> }> {
      // Validate input
      const parsed = updateTestCaseSchema.safeParse(args);
      if (!parsed.success) {
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                error: {
                  code: "VALIDATION_ERROR",
                  message: "Invalid input parameters",
                  details: parsed.error.errors,
                },
              }),
            },
          ],
        };
      }
    
      const {
        id,
        project_id,
        title,
        suite,
        description,
        priority,
        steps,
        steps_patch,
        tags,
        requirements,
        custom_fields,
        attachments,
      } = parsed.data;
      const rawArgs = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
      const hasField = (key: string) =>
        Object.prototype.hasOwnProperty.call(rawArgs, key);
      const hasSteps = hasField("steps");
      const hasStepsPatch = hasField("steps_patch");
    
      if (hasSteps && hasStepsPatch) {
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                error: {
                  code: "INVALID_INPUT",
                  message: "Provide either steps or steps_patch, not both.",
                },
              }),
            },
          ],
        };
      }
    
      // Resolve project ID
      const requestContext = getRequestContext();
      const envConfig = requestContext ? null : getConfig();
      const resolvedProjectId = project_id ?? requestContext?.defaultProjectId ?? envConfig?.defaultProjectId;
    
      if (!resolvedProjectId) {
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                error: {
                  code: "MISSING_PROJECT_ID",
                  message:
                    "project_id is required. Either provide it in the request or set TC_DEFAULT_PROJECT.",
                },
              }),
            },
          ],
        };
      }
    
      try {
        const client = getApiClient();
    
        const suiteInput = suite;
        const suiteNumericId = toNumberId(suiteInput);
        const suiteTitle = normalizeString(suiteInput);
        const suiteNeedsLookup =
          hasField("suite") &&
          suiteInput !== null &&
          suiteNumericId === undefined &&
          suiteTitle !== undefined;
        const tagsNeedLookup =
          hasField("tags") && Array.isArray(tags) && tags.some(isNonNumericString);
        const requirementsNeedLookup =
          hasField("requirements") &&
          Array.isArray(requirements) &&
          requirements.some(isNonNumericString);
        const customFieldsNeedLookup =
          hasField("custom_fields") &&
          custom_fields !== null &&
          custom_fields?.some((cf) => cf.id === undefined || isNonNumericString(cf.id));
    
        const needsCompanyId =
          tagsNeedLookup || requirementsNeedLookup || customFieldsNeedLookup;
    
        const [suitesList, projectForCompany] = await Promise.all([
          suiteNeedsLookup
            ? client.listSuites(resolvedProjectId)
            : Promise.resolve(null),
          needsCompanyId
            ? client.getProject(resolvedProjectId)
            : Promise.resolve(null),
        ]);
    
        const companyId = projectForCompany
          ? getCompanyIdFromProject(projectForCompany)
          : undefined;
    
        const [tagsList, requirementsList, customFieldsList] = await Promise.all([
          tagsNeedLookup
            ? client.listTags(resolvedProjectId)
            : Promise.resolve(null),
          requirementsNeedLookup
            ? client.listRequirements(resolvedProjectId)
            : Promise.resolve(null),
          customFieldsNeedLookup
            ? client.listProjectCustomFields(resolvedProjectId, companyId)
            : Promise.resolve(null),
        ]);
    
        // The PUT /testcases/{id} endpoint expects a full TestCasePayload.
        // Fetch current test case and merge with incoming changes to avoid partial payload errors.
        const existingRaw = await client.getTestCaseRaw(id, resolvedProjectId, {
          parseRs: hasSteps || hasStepsPatch,
        });
        const existing = unwrapApiEntity(existingRaw);
        if (!existing) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify({
                  error: {
                    code: "INVALID_TEST_CASE",
                    message: `Unable to load test case ${id} for update.`,
                  },
                }),
              },
            ],
          };
        }
    
        const existingSuiteValue = getField<unknown>(existing, "suite");
        const existingSuiteId =
          typeof existingSuiteValue === "number"
            ? existingSuiteValue
            : extractId(existingSuiteValue);
    
        type ExistingStep = {
          step: string;
          expectedResult?: string;
          reusableStepId?: number | null;
        };
    
        const stepsSource = getExistingStepsSource(existing);
        const existingSteps: ExistingStep[] | undefined = stepsSource?.map((s) => ({
          step: getStepText(s) ?? "",
          expectedResult: getStepExpectedResult(s),
          reusableStepId: getStepReusableId(s) ?? null,
        }));
    
        const existingTags = getArrayField(existing, "tags")
          ?.map((t) => extractId(t))
          .filter((id): id is number => typeof id === "number");
        const existingRequirements = getArrayField(existing, "requirements")
          ?.map((r) => extractId(r))
          .filter((id): id is number => typeof id === "number");
        const existingAttachments = getArrayField(existing, "attachments")
          ?.map((a) => {
            const attachmentId = extractId(a);
            return attachmentId !== undefined ? String(attachmentId) : undefined;
          })
          .filter((id): id is string => typeof id === "string");
        const existingCustomFields = getArrayField(existing, "customFields", [
          "custom_fields",
        ])
          ?.map((cf) => {
            const cfId = extractId(cf);
            const name = normalizeString(getField<string>(cf, "name")) ?? "";
            if (cfId === undefined || name.length === 0) {
              return undefined;
            }
            return {
              id: cfId,
              name,
              label: getField<string>(cf, "label"),
              value:
                (getField<string | number | null>(cf, "value") ?? null),
              valueLabel:
                getField<string>(cf, "valueLabel") ??
                getField<string>(cf, "value_label"),
              color: getField<string>(cf, "color"),
            };
          })
          .filter(
            (
              cf
            ): cf is {
              id: number;
              name: string;
              label: string | undefined;
              value: string | number | null;
              valueLabel: string | undefined;
              color: string | undefined;
            } => cf !== undefined
          );
    
        const existingTitle = getField<string>(existing, "title");
        const existingDescription = getField<string | null>(
          existing,
          "description"
        );
        const existingPriority = toNumberId(getField<unknown>(existing, "priority"));
    
        const resolvedTitle =
          hasField("title") && title !== undefined ? title : existingTitle;
        const resolvedDescription = hasField("description")
          ? description
          : existingDescription;
        const resolvedPriority =
          hasField("priority") && priority !== undefined
            ? priority
            : existingPriority;
        let resolvedSuiteId: number | null | undefined =
          hasField("suite")
            ? suiteInput === null
              ? null
              : suiteNumericId
            : existingSuiteId;
        if (suiteNeedsLookup && suitesList && suiteInput !== null) {
          const normalizedSuiteTitle = suiteTitle?.toLowerCase();
          const match = suitesList.find((suiteItem) => {
            const title = normalizeString(getField<string>(suiteItem, "title"));
            return (
              title !== undefined &&
              normalizedSuiteTitle !== undefined &&
              title.toLowerCase() === normalizedSuiteTitle
            );
          });
          resolvedSuiteId = toNumberId(match ? getField(match, "id") : undefined);
          if (resolvedSuiteId === undefined) {
            return {
              content: [
                {
                  type: "text",
                  text: JSON.stringify({
                    error: {
                      code: "SUITE_NOT_FOUND",
                      message: `Suite not found with title "${suiteTitle}" in that project`,
                    },
                  }),
                },
              ],
            };
          }
        }
        if (
          hasField("suite") &&
          suiteInput !== null &&
          resolvedSuiteId === undefined
        ) {
          return {
            content: [
              {
                type: "text",
                text: JSON.stringify({
                error: {
                  code: "INVALID_SUITE_ID",
                  message: "suite must be a numeric ID or suite title",
                },
              }),
            },
          ],
        };
        }
        let patchedStepsResult:
          | Array<{
              step: string;
              expected_result?: string;
              reusable_step_id?: number | null;
            }>
          | null
          | undefined;
        if (hasStepsPatch) {
          if (!existingSteps || existingSteps.length === 0) {
            return {
              content: [
                {
                  type: "text",
                  text: JSON.stringify({
                    error: {
                      code: "MISSING_STEPS",
                      message:
                        "Cannot patch steps because the test case has no steps.",
                    },
                  }),
                },
              ],
            };
          }
    
          const outOfRange = (steps_patch ?? []).find((patch) => {
            const index = patch.step_number - 1;
            return index < 0 || index >= existingSteps.length;
          });
          if (outOfRange) {
            return {
              content: [
                {
                  type: "text",
                  text: JSON.stringify({
                    error: {
                      code: "INVALID_STEP_NUMBER",
                      message: `Step number ${outOfRange.step_number} is out of range for test case ${id}.`,
                    },
                  }),
                },
              ],
            };
          }
    
          const patchedSteps = existingSteps.map((s) => ({
            step: s.step,
            expectedResult: s.expectedResult,
            reusableStepId: s.reusableStepId ?? null,
          }));
    
          (steps_patch ?? []).forEach((patch) => {
            const index = patch.step_number - 1;
            const target = patchedSteps[index];
            if (!target) {
              return;
            }
            if (patch.step !== undefined) {
              target.step = patch.step;
            }
            if (patch.expected_result !== undefined) {
              target.expectedResult = patch.expected_result;
            }
          });
    
          patchedStepsResult = patchedSteps.map((s) => ({
            step: s.step,
            expected_result: s.expectedResult,
            reusable_step_id: s.reusableStepId ?? null,
          }));
        }
    
        const resolvedSteps = hasSteps
          ? steps === null
            ? null
            : steps?.map((s) => ({
                step: s.step,
                expected_result: s.expected_result,
              }))
          : hasStepsPatch
            ? patchedStepsResult
            : existingSteps?.map((s) => ({
                step: s.step,
                expected_result: s.expectedResult,
                reusable_step_id: s.reusableStepId ?? null,
              }));
        const resolvedTags = hasField("tags")
          ? tags === null
            ? null
            : tags
                ?.map((tag) => {
                  const numericId = toNumberId(tag);
                  if (numericId !== undefined) {
                    return numericId;
                  }
                  if (!tagsList || typeof tag !== "string") {
                    return undefined;
                  }
                  const match = tagsList.find(
                    (t) => getField<string>(t, "name") === tag
                  );
                  return toNumberId(match ? getField(match, "id") : undefined);
                })
                .filter((id): id is number => typeof id === "number")
          : existingTags;
        const resolvedRequirements = hasField("requirements")
          ? requirements === null
            ? null
            : requirements
                ?.map((req) => {
                  const numericId = toNumberId(req);
                  if (numericId !== undefined) {
                    return numericId;
                  }
                  if (!requirementsList || typeof req !== "string") {
                    return undefined;
                  }
                  const match = requirementsList.find((r) => {
                    const key = getField<string>(r, "requirement_key");
                    const reqId = getField<string>(r, "requirement_id");
                    const title = getField<string>(r, "title");
                    return key === req || reqId === req || title === req;
                  });
                  return toNumberId(match ? getField(match, "id") : undefined);
                })
                .filter((id): id is number => typeof id === "number")
          : existingRequirements;
    
        const customFieldMap = customFieldsList
          ? customFieldsList.reduce((map, cf) => {
              const name = getField<string>(cf, "name");
              const id = toNumberId(getField(cf, "id"));
              if (!name || id === undefined) {
                return map;
              }
              const fieldType =
                getField<string>(cf, "field_type") ?? getField<string>(cf, "type");
              const options = getCustomFieldOptions(cf);
              map.set(name, {
                id,
                name,
                label: getField<string>(cf, "label"),
                fieldType,
                options,
              });
              return map;
            }, new Map<string, { id: number; name: string; label?: string; fieldType?: string; options?: unknown[] | null }>())
          : null;
    
        const resolvedCustomFields = hasField("custom_fields")
          ? custom_fields === null
            ? null
            : custom_fields
                ?.map((cf) => {
                  const numericId = toNumberId(cf.id);
                  if (numericId !== undefined) {
                    return {
                      id: numericId,
                      name: cf.name,
                      value: cf.value,
                      ...(cf.label !== undefined ? { label: cf.label } : {}),
                      ...(cf.valueLabel !== undefined ? { valueLabel: cf.valueLabel } : {}),
                      ...(cf.color !== undefined ? { color: cf.color } : {}),
                    };
                  }
                  if (!customFieldMap) {
                    return undefined;
                  }
                  const match = customFieldMap.get(cf.name);
                  if (!match) {
                    return undefined;
                  }
                  const { value: resolvedValue, valueLabel: resolvedValueLabel } =
                    resolveDropdownValue(
                      match.fieldType,
                      match.options,
                      cf.value,
                      cf.valueLabel
                    );
                  return {
                    id: match.id,
                    name: match.name,
                    value: resolvedValue,
                    ...(cf.label !== undefined ? { label: cf.label } : {}),
                    ...(resolvedValueLabel !== undefined
                      ? { valueLabel: resolvedValueLabel }
                      : {}),
                    ...(cf.color !== undefined ? { color: cf.color } : {}),
                    ...(cf.label === undefined && match.label !== undefined
                      ? { label: match.label }
                      : {}),
                  };
                })
                .filter(
                  (
                    cf
                  ): cf is {
                    id: number;
                    name: string;
                    label?: string;
                    value: string | number | null;
                    valueLabel?: string;
                    color?: string;
                  } => cf !== undefined
                )
          : existingCustomFields;
        const resolvedAttachments = hasField("attachments")
          ? attachments
          : existingAttachments;
    
        const payload = {
          title: resolvedTitle,
          description: resolvedDescription ?? null,
          priority: resolvedPriority,
          suiteId: resolvedSuiteId ?? null,
          steps: resolvedSteps === undefined ? [] : resolvedSteps,
          tags: resolvedTags === undefined ? [] : resolvedTags,
          requirements: resolvedRequirements === undefined ? [] : resolvedRequirements,
          customFields: resolvedCustomFields === undefined ? [] : resolvedCustomFields,
          attachments: resolvedAttachments === undefined ? [] : resolvedAttachments,
        };
    
        const result = await client.updateTestCase(id, resolvedProjectId, payload);
    
        // Priority labels
        const priorityLabels: Record<number, string> = {
          0: "Low",
          1: "Normal",
          2: "High",
        };
    
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify(
                {
                  success: true,
                  message: `Test case ${id} updated successfully`,
                  testCase: {
                    id: result.id,
                    title: result.title,
                    project: result.project,
                    suite: result.suite,
                    priority: result.priority,
                    priorityLabel: priorityLabels[result.priority] ?? "Unknown",
                  },
                },
                null,
                2
              ),
            },
  • The updateTestCaseSchema defines the validation schema for the input arguments of the update_test_case tool using zod.
    export const updateTestCaseSchema = z.object({
      id: z.number().describe("Test case ID to update (required)"),
      project_id: z
        .number()
        .optional()
        .describe("Project ID (uses default if not specified)"),
      title: z.string().min(1).optional().describe("New test case title"),
      suite: z
        .union([z.number(), z.string()])
        .nullable()
        .optional()
        .describe("Move to a different suite by ID or title (null to remove)"),
      description: z
        .string()
        .nullable()
        .optional()
        .describe("New description (HTML supported, null to clear)"),
      priority: z
        .number()
        .min(0)
        .max(2)
        .optional()
        .describe("New priority: 0=Low, 1=Normal, 2=High"),
      steps: z
        .array(stepSchema)
        .nullable()
        .optional()
        .describe("Replace all steps with this array (null to clear)"),
      steps_patch: z
        .array(stepPatchSchema)
        .optional()
        .describe(
          "Patch existing steps by step number (1-based) without replacing the entire steps array"
        ),
      tags: z
        .array(z.union([z.number(), z.string()]))
        .nullable()
        .optional()
        .describe("Replace tags with these IDs or names (null to clear)"),
      requirements: z
        .array(z.union([z.number(), z.string()]))
        .nullable()
        .optional()
        .describe("Replace requirements with these IDs or names (null to clear)"),
      custom_fields: z
        .array(customFieldSchema)
        .nullable()
        .optional()
        .describe("Update custom field values (null to clear)"),
      attachments: z
        .array(z.string())
        .nullable()
        .optional()
        .describe("Replace attachments with these file IDs (null to clear)"),
    });
    
    export type UpdateTestCaseInput = z.infer<typeof updateTestCaseSchema>;
  • The updateTestCaseTool object contains the tool definition, including the name "update_test_case", description, and input schema.
    export const updateTestCaseTool = {
      name: "update_test_case",
      description: `Update an existing test case in TestCollab.

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