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.
Behavior4/5

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

No annotations provided, so description carries full burden. It discloses partial-update behavior, HTML support for descriptions, and 1-based indexing for steps_patch. Missing: idempotency guarantees, permission requirements, or conflict resolution behavior.

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

Conciseness4/5

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

Well-structured with front-loaded purpose, followed by prerequisite tips, field categorization (Required/Optional), and concrete JSON examples. Length is justified by complexity (12 parameters), though the embedded examples make it verbose.

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?

Adequately covers complexity of partial updates and ID resolution workflows for a 12-parameter tool. No output schema exists, but description appropriately focuses on input requirements and mutation semantics rather than return values.

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?

Schema has 100% coverage (baseline 3), but description adds crucial usage context: narrative explanation of steps vs steps_patch workflows, priority value mappings (0=Low, etc.), and the critical tip that suite accepts null to remove assignment—context not obvious from schema alone.

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?

Description opens with specific verb+resource ('Update an existing test case in TestCollab') and immediately clarifies patch semantics ('Only provided fields will be updated'), distinguishing it from create_test_case and explaining partial vs full updates.

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

Usage Guidelines5/5

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

Explicitly names prerequisite workflows ('Call get_project_context first', 'call get_test_case first') and clarifies when to use steps vs steps_patch ('Replaces all existing steps' vs 'Patch steps by step number'), providing clear decision criteria for parameter selection.

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