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
| Name | Required | Description | Default |
|---|---|---|---|
| project_id | No | Project ID (optional if TC_DEFAULT_PROJECT env var is set) | |
| suite | No | Filter by suite ID or title | |
| filter | No | Filter conditions object | |
| sort | No | Sort specification array, e.g. [{ colId: 'updated_at', sort: 'desc' }] | |
| limit | No | Maximum results to return (1-100, default: 50) | |
| offset | No | Number of results to skip (default: 0) |
Implementation Reference
- src/tools/test-cases/list.ts:588-1183 (handler)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, }, }), }, ], }; } } - src/tools/test-cases/list.ts:145-170 (schema)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)"), }) ); - src/tools/test-cases/list.ts:178-244 (registration)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: [], }, };