Skip to main content
Glama
TCSoftInc

TestCollab MCP Server

by TCSoftInc

list_test_cases

Retrieve test cases from TestCollab projects with filtering, sorting, and pagination options to manage testing workflows efficiently.

Instructions

List test cases from a TestCollab project with optional filtering, sorting, and pagination. Tip: Call get_project_context first to resolve suite/tag/custom field names to IDs. Note: list_test_cases may omit full step details; use get_test_case for a complete test case with steps.

Filter fields include:

  • id, title, description, steps, priority (0=Low, 1=Normal, 2=High)

  • suite (ID or title), created_by, reviewer, poster (user IDs)

  • created_at, updated_at, last_run_on (dates)

  • tags, requirements (arrays of IDs or names)

  • under_review, is_automated (0 or 1)

  • run_count, avg_execution_time, failure_rate

Filter types:

  • text: equals, notEqual, contains, notContains, startsWith, endsWith, isBlank

  • number: equals, notEqual, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, inRange

  • date: equals, notEqual, greaterThan, lessThan, inRange

Example filter: { "priority": { "filterType": "number", "type": "greaterThanOrEqual", "filter": 1 }, "title": { "filterType": "text", "type": "contains", "filter": "login" } }

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
project_idNoProject ID (optional if TC_DEFAULT_PROJECT env var is set)
suiteNoFilter by suite ID or title
filterNoFilter conditions object
sortNoSort specification array, e.g. [{ colId: 'updated_at', sort: 'desc' }]
limitNoMaximum results to return (1-100, default: 50)
offsetNoNumber of results to skip (default: 0)

Implementation Reference

  • The handleListTestCases function, which acts as the MCP tool handler for 'list_test_cases', managing input parsing, project/suite resolution, filtering, and API invocation.
    export async function handleListTestCases(
      args: unknown
    ): Promise<{ content: Array<{ type: "text"; text: string }> }> {
      // Validate input
      const parsed = listTestCasesSchema.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 { project_id, suite, filter, sort, limit, offset } = parsed.data;
    
      // Resolve project ID: use provided value or fall back to default
      // Check request context first (HTTP transport), then env config (stdio transport)
      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 environment variable.",
                },
              }),
            },
          ],
        };
      }
    
      try {
        const client = getApiClient();
    
        const suiteNeedsLookup =
          isNonNumericString(suite) ||
          (filter?.suite !== undefined &&
            (Array.isArray(filter.suite?.filter)
              ? filter.suite.filter.some(isNonNumericString)
              : isNonNumericString(filter.suite?.filter)));
        const tagsNeedLookup =
          filter?.tags !== undefined &&
          (Array.isArray(filter.tags.filter)
            ? filter.tags.filter.some(isNonNumericString)
            : isNonNumericString(filter.tags.filter));
        const requirementsNeedLookup =
          filter?.requirements !== undefined &&
          (Array.isArray(filter.requirements.filter)
            ? filter.requirements.filter.some(isNonNumericString)
            : isNonNumericString(filter.requirements.filter));
    
        const customFieldNameKeys =
          filter && typeof filter === "object"
            ? Object.keys(filter).filter(
                (key) => !standardFilterKeys.has(key) && !key.startsWith("cf_")
              )
            : [];
        const customFieldOptionsNeedLookup =
          filter && typeof filter === "object"
            ? Object.entries(filter).some(([key, value]) => {
                if (standardFilterKeys.has(key)) {
                  return false;
                }
                if (!value || typeof value !== "object" || Array.isArray(value)) {
                  return false;
                }
                const filterType = (value as Record<string, unknown>)["filterType"];
                const filterValue = (value as Record<string, unknown>)["filter"];
                if (filterType !== "text" || typeof filterValue !== "string") {
                  return false;
                }
                return isNonNumericString(filterValue);
              })
            : false;
        const customFieldsNeedLookup = customFieldNameKeys.length > 0;
    
        const needsLookup =
          suiteNeedsLookup ||
          tagsNeedLookup ||
          requirementsNeedLookup ||
          customFieldsNeedLookup ||
          customFieldOptionsNeedLookup;
        const cachedContext = needsLookup
          ? getCachedProjectContext(resolvedProjectId)
          : null;
        const cachedSuites = suiteNeedsLookup
          ? flattenSuiteTree(cachedContext?.suites)
          : null;
        const cachedTags = tagsNeedLookup ? cachedContext?.tags ?? null : null;
        const cachedRequirements = requirementsNeedLookup
          ? cachedContext?.requirements ?? null
          : null;
        const cachedCustomFields =
          customFieldsNeedLookup || customFieldOptionsNeedLookup
            ? cachedContext?.custom_fields ?? null
            : null;
    
        if (
          cachedSuites ||
          cachedTags ||
          cachedRequirements ||
          cachedCustomFields
        ) {
          console.log(
            `${logPrefix} Using cached project context for list_test_cases lookups (project ${resolvedProjectId})`
          );
        }
    
        const [suitesListResponse, projectForCompany] = await Promise.all([
          suiteNeedsLookup && !cachedSuites
            ? client.listSuites(resolvedProjectId)
            : Promise.resolve(null),
          (customFieldsNeedLookup || customFieldOptionsNeedLookup) &&
          !cachedCustomFields
            ? client.getProject(resolvedProjectId)
            : Promise.resolve(null),
        ]);
    
        const companyId = projectForCompany
          ? getCompanyIdFromProject(projectForCompany)
          : undefined;
    
        const [tagsListResponse, requirementsListResponse, customFieldsListResponse] =
          await Promise.all([
            tagsNeedLookup && !cachedTags
              ? client.listTags(resolvedProjectId)
              : Promise.resolve(null),
            requirementsNeedLookup && !cachedRequirements
              ? client.listRequirements(resolvedProjectId)
              : Promise.resolve(null),
            (customFieldsNeedLookup || customFieldOptionsNeedLookup) &&
            !cachedCustomFields
              ? client.listProjectCustomFields(resolvedProjectId, companyId)
              : Promise.resolve(null),
          ]);
    
        const suitesList = cachedSuites ?? suitesListResponse;
        const tagsList = cachedTags ?? tagsListResponse;
        const requirementsList = cachedRequirements ?? requirementsListResponse;
        const customFieldsList = cachedCustomFields ?? customFieldsListResponse;
    
        let resolvedSuiteId = toNumberId(suite);
        if (isNonNumericString(suite) && suitesList) {
          const match = suitesList.find(
            (suiteItem) => getField<string>(suiteItem, "title") === suite
          );
          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 "${suite}" in that project`,
                    },
                  }),
                },
              ],
            };
          }
        }
    
        const resolvedFilter: Record<string, any> | undefined = filter
          ? { ...filter }
          : undefined;
    
        const suiteFilter = resolvedFilter?.suite as {
          filter?: unknown;
          filterType?: unknown;
          type?: unknown;
        } | undefined;
        if (suiteFilter && suiteFilter.filter !== undefined) {
          const rawValues = toArray(suiteFilter.filter);
          const shouldLookupByText =
            suiteFilter.filterType === "text" || rawValues.some(isNonNumericString);
    
          if (shouldLookupByText) {
            const match = resolveTextMatch(suiteFilter.type);
            if (!match) {
              return {
                content: [
                  {
                    type: "text",
                    text: JSON.stringify({
                      error: {
                        code: "UNSUPPORTED_FILTER",
                        message:
                          "Suite text filter type is not supported for lookups. Use equals/contains/startsWith/endsWith or notEqual/notContains.",
                      },
                    }),
                  },
                ],
              };
            }
            const { ids: resolvedIds, missing: missingSuites } = resolveLookupIds(
              rawValues,
              suitesList,
              match.match,
              (suite) => [getField<string>(suite, "title")]
            );
            if (missingSuites.length > 0) {
              return {
                content: [
                  {
                    type: "text",
                    text: JSON.stringify({
                      error: {
                        code: "SUITE_NOT_FOUND",
                        message: `Suite not found with title "${missingSuites.join(", ")}" in that project`,
                      },
                    }),
                  },
                ],
              };
            }
            if (resolvedIds.length > 0 && resolvedFilter) {
              resolvedFilter.suite = {
                ...suiteFilter,
                filterType: "number",
                type: match.negative ? "notEqual" : "equals",
                filter: resolvedIds.length === 1 ? resolvedIds[0] : resolvedIds,
              };
            }
          } else {
            const resolvedIds = rawValues
              .map((value) => toNumberId(value))
              .filter((id): id is number => typeof id === "number");
            if (resolvedIds.length > 0 && resolvedFilter) {
              resolvedFilter.suite = {
                ...suiteFilter,
                filterType: "number",
                filter: resolvedIds.length === 1 ? resolvedIds[0] : resolvedIds,
              };
            }
          }
        }
    
        if (resolvedFilter?.tags) {
          const tagsFilter = resolvedFilter.tags as {
            filter?: unknown;
            filterType?: unknown;
            type?: unknown;
          };
          if (tagsFilter.filter !== undefined) {
            const rawValues = toArray(tagsFilter.filter);
            const numericIds = rawValues
              .map((value) => toNumberId(value))
              .filter((id): id is number => typeof id === "number");
            const nameValues = rawValues.filter(isNonNumericString);
    
            let resolvedIds = [...numericIds];
            if (nameValues.length > 0) {
              const { ids: nameIds, missing: missingTags } = resolveLookupIds(
                nameValues,
                tagsList,
                "equals",
                (tag) => [getField<string>(tag, "name")]
              );
              if (missingTags.length > 0) {
                return {
                  content: [
                    {
                      type: "text",
                      text: JSON.stringify({
                        error: {
                          code: "TAG_NOT_FOUND",
                          message: `Tag(s) not found: ${missingTags.join(", ")}`,
                        },
                      }),
                    },
                  ],
                };
              }
              resolvedIds = resolvedIds.concat(nameIds);
            }
    
            if (resolvedIds.length > 0) {
              resolvedFilter.tags = {
                ...tagsFilter,
                filterType: "number",
                type: normalizeTagMatchType(tagsFilter.type),
                filter: resolvedIds,
              };
            }
          }
        }
    
        if (resolvedFilter?.requirements) {
          const requirementsFilter = resolvedFilter.requirements as {
            filter?: unknown;
            filterType?: unknown;
            type?: unknown;
          };
          if (requirementsFilter.filter !== undefined) {
            const rawValues = toArray(requirementsFilter.filter);
            const shouldLookupByText =
              requirementsFilter.filterType === "text" ||
              rawValues.some(isNonNumericString);
    
            if (shouldLookupByText) {
              const match = resolveTextMatch(requirementsFilter.type);
              if (!match) {
                return {
                  content: [
                    {
                      type: "text",
                      text: JSON.stringify({
                        error: {
                          code: "UNSUPPORTED_FILTER",
                          message:
                            "Requirement text filter type is not supported for lookups. Use equals/contains/startsWith/endsWith or notEqual/notContains.",
                        },
                      }),
                    },
                  ],
                };
              }
              const { ids: resolvedIds, missing: missingRequirements } =
                resolveLookupIds(
                  rawValues,
                  requirementsList,
                  match.match,
                  (req) => [
                    getField<string>(req, "requirement_key"),
                    getField<string>(req, "requirement_id"),
                    getField<string>(req, "title"),
                  ]
                );
              if (missingRequirements.length > 0) {
                return {
                  content: [
                    {
                      type: "text",
                      text: JSON.stringify({
                        error: {
                          code: "REQUIREMENT_NOT_FOUND",
                          message: `Requirement(s) not found: ${missingRequirements.join(", ")}`,
                        },
                      }),
                    },
                  ],
                };
              }
              resolvedFilter.requirements = {
                ...requirementsFilter,
                filterType: "number",
                type: match.negative ? "notContains" : "contains",
                filter: resolvedIds,
              };
            } else {
              const resolvedIds = rawValues
                .map((value) => toNumberId(value))
                .filter((id): id is number => typeof id === "number");
              resolvedFilter.requirements = {
                ...requirementsFilter,
                filterType: "number",
                type: normalizeTagMatchType(requirementsFilter.type),
                filter: resolvedIds,
              };
            }
          }
        }
    
        if (customFieldsNeedLookup && resolvedFilter && customFieldsList) {
          const resolvedFilterRecord = resolvedFilter as Record<string, unknown>;
          const customFieldNameMap = customFieldsList.reduce((map, cf) => {
            const name = getField<string>(cf, "name");
            const id = toNumberId(getField(cf, "id"));
            if (!name || id === undefined) {
              return map;
            }
            map.set(name, id);
            return map;
          }, new Map<string, number>());
    
          const missingCustomFields: string[] = [];
          customFieldNameKeys.forEach((key) => {
            const customFieldId = customFieldNameMap.get(key);
            if (customFieldId === undefined) {
              missingCustomFields.push(key);
              return;
            }
            const value = resolvedFilterRecord[key];
            delete resolvedFilterRecord[key];
            resolvedFilterRecord[`cf_${customFieldId}`] = value;
          });
          if (missingCustomFields.length > 0) {
            return {
              content: [
                {
                  type: "text",
                  text: JSON.stringify({
                    error: {
                      code: "CUSTOM_FIELD_NOT_FOUND",
                      message: `Custom field(s) not found: ${missingCustomFields.join(", ")}`,
                    },
                  }),
                },
              ],
            };
          }
        }
    
        if (customFieldOptionsNeedLookup && resolvedFilter && customFieldsList) {
          const resolvedFilterRecord = resolvedFilter as Record<string, unknown>;
          const missingCustomFieldOptions: string[] = [];
          const ambiguousCustomFieldOptions: string[] = [];
    
          Object.entries(resolvedFilterRecord)
            .filter(([key]) => key.startsWith("cf_"))
            .forEach(([key, value]) => {
              if (!value || typeof value !== "object" || Array.isArray(value)) {
                return;
              }
              const filterType = (value as Record<string, unknown>)["filterType"];
              const filterValue = (value as Record<string, unknown>)["filter"];
              if (filterType !== "text" || typeof filterValue !== "string") {
                return;
              }
              if (!isNonNumericString(filterValue)) {
                return;
              }
              const fieldId = getCustomFieldIdFromKey(key);
              if (!fieldId) {
                return;
              }
              const field = customFieldsList.find(
                (customField) => toNumberId(getField(customField, "id")) === fieldId
              );
              if (!field) {
                return;
              }
              const fieldType =
                getField<string>(field, "field_type") ??
                getField<string>(field, "type");
              if (!isDropdownFieldType(fieldType)) {
                return;
              }
              const options = getCustomFieldOptions(field);
              if (!options) {
                return;
              }
              const lookups = buildOptionLookup(options);
              if (!lookups.length) {
                return;
              }
              const optionIds = new Set(lookups.map((lookup) => lookup.id));
              if (optionIds.has(filterValue.trim())) {
                return;
              }
              const match = resolveTextMatch(
                (value as Record<string, unknown>)["type"]
              );
              if (!match) {
                return;
              }
              const matches = lookups.filter((lookup) =>
                matchTextValue(lookup.label, filterValue, match.match)
              );
              if (matches.length === 1) {
                resolvedFilterRecord[key] = {
                  ...value,
                  filter: matches[0].id,
                };
                return;
              }
              const displayName = getCustomFieldDisplayName(field, key);
              if (matches.length === 0) {
                missingCustomFieldOptions.push(
                  `${displayName}=${filterValue}`
                );
                return;
              }
              ambiguousCustomFieldOptions.push(
                `${displayName}=${filterValue}`
              );
            });
    
          if (
            missingCustomFieldOptions.length > 0 ||
            ambiguousCustomFieldOptions.length > 0
          ) {
            const details: string[] = [];
            if (missingCustomFieldOptions.length > 0) {
              details.push(
                `Missing: ${missingCustomFieldOptions.join(", ")}`
              );
            }
            if (ambiguousCustomFieldOptions.length > 0) {
              details.push(
                `Ambiguous: ${ambiguousCustomFieldOptions.join(", ")} (use exact label)`
              );
            }
            return {
              content: [
                {
                  type: "text",
                  text: JSON.stringify({
                    error: {
                      code: "CUSTOM_FIELD_OPTION_NOT_FOUND",
                      message: `Custom field option lookup failed. ${details.join(
                        " "
                      )}`,
                    },
                  }),
                },
              ],
            };
          }
        }
    
        const result = await client.listTestCases({
          projectId: resolvedProjectId,
          suiteId: resolvedSuiteId,
          filter: resolvedFilter as TestCaseFilter | undefined,
          sort: sort,
          limit: limit,
          offset: offset,
        });
    
        // Priority labels
        const priorityLabels: Record<number, string> = {
          0: "Low",
          1: "Normal",
          2: "High",
        };
    
        // Transform rows to include human-readable labels
        const humanizedRows = result.rows.map((tc) => ({
          id: tc.id,
          title: tc.title,
          description: tc.description,
          priority: tc.priority,
          priorityLabel: priorityLabels[tc.priority] ?? "Unknown",
          suite: typeof tc.suite === "object" ? tc.suite?.id : tc.suite,
          suiteTitle: typeof tc.suite === "object" ? tc.suite?.title : tc.suite_title,
          project: typeof tc.project === "object" ? tc.project?.id : tc.project,
          projectTitle: typeof tc.project === "object" ? tc.project?.title : undefined,
          tags: tc.tags?.map((t) => ({ id: t.id, name: t.name })),
          createdBy: tc.created_by?.name,
          createdAt: tc.created_at,
          updatedAt: tc.updated_at,
          isAutomated: tc.is_automated === 1 || tc.is_automated === true,
          automationStatus: tc.automation_status,
          runCount: tc.run_count,
          lastRunOn: tc.last_run_on,
          steps: tc.steps ?? tc.stepsParsed,
        }));
    
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                testCases: humanizedRows,
                totalCount: result.totalCount,
                filteredCount: result.filteredCount,
                returned: humanizedRows.length,
              }, null, 2),
            },
          ],
        };
      } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        return {
          content: [
            {
              type: "text",
              text: JSON.stringify({
                error: {
                  code: "API_ERROR",
                  message: message,
                },
              }),
            },
          ],
        };
      }
    }
  • The Zod schema used to validate and preprocess input for the list_test_cases tool.
    export const listTestCasesSchema = z.preprocess(
      normalizeListTestCasesInput,
      z.object({
        project_id: z.number().optional().describe("Project ID (uses TC_DEFAULT_PROJECT env var if not specified)"),
        suite: z
          .union([z.number(), z.string()])
          .optional()
          .describe("Filter by suite ID or suite title"),
        filter: testCaseFilterSchema.optional().describe("Filter conditions object"),
        sort: z
          .array(sortModelSchema)
          .optional()
          .describe("Sort specification array"),
        limit: z
          .number()
          .min(1)
          .max(100)
          .default(50)
          .describe("Maximum results to return (1-100, default: 50)"),
        offset: z
          .number()
          .min(0)
          .default(0)
          .describe("Number of results to skip (default: 0)"),
      })
    );
  • The MCP tool definition for list_test_cases, including its name, description, and input schema.
    export const listTestCasesTool = {
      name: "list_test_cases",
      description: `List test cases from a TestCollab project with optional filtering, sorting, and pagination.
    
    Before calling this function, make sure project context is available.
    Note: list_test_cases may omit full step details; use get_test_case for a complete test case with steps.
    
    Filter fields include:
    - id, title, description, steps, priority (0=Low, 1=Normal, 2=High)
    - suite (ID), created_by, reviewer, poster (user IDs)
    - created_at, updated_at, last_run_on (dates)
    - tags, requirements (arrays of IDs or names)
    - under_review, is_automated (0 or 1)
    - run_count, avg_execution_time, failure_rate
    
    Filter types:
    - text: equals, notEqual, contains, notContains, startsWith, endsWith, isBlank
    - number: equals, notEqual, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, inRange
    - date: equals, notEqual, greaterThan, lessThan, inRange`,
    
      inputSchema: {
        type: "object" as const,
        properties: {
          project_id: {
            type: "number",
            description: "Project ID (optional if TC_DEFAULT_PROJECT env var is set)",
          },
          suite: {
            oneOf: [{ type: "number" }, { type: "string" }],
            description:
              "Filter by suite ID or suite title. If suite title is provided, map to ID from project context.",
          },
          filter: {
            type: "object",
            description:
              "Filter conditions. Each key is a field name with a filter object containing filterType, type, and filter value.",
            additionalProperties: true,
          },
          sort: {
            type: "array",
            description: "Sort specification",
            items: {
              type: "object",
              properties: {
                colId: { type: "string", description: "Field name to sort by" },
                sort: { type: "string", enum: ["asc", "desc"] },
              },
              required: ["colId", "sort"],
            },
          },
          limit: {
            type: "number",
            description: "Maximum results to return (1-100, default: 50)",
            default: 50,
            minimum: 1,
            maximum: 100,
          },
          offset: {
            type: "number",
            description: "Number of results to skip (default: 0)",
            default: 0,
            minimum: 0,
          },
        },
        required: [],
      },
    };

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