qasphere-mcp
Version:
MCP server for QA Sphere integration
626 lines (625 loc) • 29.8 kB
JavaScript
/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: docstrings describe QASphere's `${var}` placeholder syntax, not JS template literals */
import z from 'zod';
// ---------- Reusable output sub-schemas ----------
const projectCodeSchema = z
.string()
.regex(/^[A-Z0-9]{2,5}$/, 'Project code must be 2 to 5 uppercase alphanumeric characters (e.g., BDI)')
.describe('Project code identifier (e.g., BDI)');
const testCaseMarkerSchema = z
.string()
.regex(/^[A-Z0-9]{2,5}-\d+$/, 'Marker must be in format PROJECT_CODE-TEST_CASE_SEQUENCE (e.g., BDI-123). Project code must be 2 to 5 characters in format PROJECT_CODE (e.g., BDI). Test case sequence must be a number.')
.describe('Test case marker in format PROJECT_CODE-TEST_CASE_SEQUENCE (e.g., BDI-123)');
const testCaseStepInputSchema = z.object({
sharedStepId: z
.number()
.int()
.positive('Shared step ID must be a positive integer')
.optional()
.describe('Use a shared step by specifying its unique id. Skip for standalone step'),
description: z
.string()
.optional()
.describe('Action to be performed in the step (HTML format). Required for standalone steps; skip for shared steps. In template test cases, may contain `${var}` placeholders that are substituted via `parameterValues` to produce filled test cases.'),
expected: z
.string()
.optional()
.describe('Expected result from the step (HTML format). Required for standalone steps; skip for shared steps. In template test cases, may contain `${var}` placeholders that are substituted via `parameterValues` to produce filled test cases.'),
});
const testCaseRequirementInputSchema = z.object({
text: z
.string()
.min(1, 'Requirement text must be at least 1 character')
.max(255, 'Requirement text must be at most 255 characters')
.describe('Title of the requirement'),
url: z
.url('Requirement URL must be a valid URL')
.max(255, 'Requirement URL must be at most 255 characters')
.or(z.literal(''))
.describe('URL of the requirement'),
});
const testCaseLinkInputSchema = z.object({
text: z
.string()
.min(1, 'Link text must be at least 1 character')
.max(255, 'Link text must be at most 255 characters')
.describe('Title of the link'),
url: z
.url('Link URL must be a valid URL')
.min(1, 'Link URL must be at least 1 character')
.max(255, 'Link URL must be at most 255 characters')
.describe('URL of the link'),
});
const testCasePreconditionInputSchema = z.object({
sharedPreconditionId: z
.number()
.int()
.positive('Shared precondition ID must be a positive integer')
.optional()
.describe('Use a shared precondition by specifying its unique id. Skip for standalone precondition'),
text: z
.string()
.optional()
.describe('Standalone precondition text (HTML format). Skip for shared precondition. In template test cases, may contain `${var}` placeholders that are substituted via `parameterValues` to produce filled test cases.'),
});
const testCaseCustomFieldInputSchema = z.object({
isDefault: z
.boolean()
.describe('Whether to use the default value (only if the field defines one)'),
value: z.string().optional().describe('Custom field value (omit when setting the default)'),
});
export const testStepSchemaShape = {
id: z.number().describe('Unique identifier of the step'),
version: z.number().describe('Version of the step'),
isLatest: z.boolean().describe('Whether this is the latest version of the step'),
type: z
.string()
.describe('Type of the step. Known values: "standalone" | "shared" | "shared_sub_step"'),
title: z.string().optional().describe('Title of the step (only for shared steps)'),
description: z
.string()
.optional()
.describe('Details of the step (HTML; only for standalone and shared_sub_step)'),
expected: z
.string()
.optional()
.describe('Expected result from the step (HTML; only for standalone and shared_sub_step)'),
get subSteps() {
return z
.array(testStepSchema)
.optional()
.describe("Sub-steps of a shared step, each with type 'shared_sub_step' (present only on shared steps)");
},
deletedAt: z.string().optional().describe('Date the step was deleted on (ISO 8601)'),
};
const testStepSchema = z.object(testStepSchemaShape);
const testTagSchema = z.object({
id: z.number().describe('Tag identifier'),
title: z.string().describe('Tag title'),
});
const testFileSchema = z.object({
id: z.string().describe('File identifier'),
fileName: z.string().describe('Original file name'),
mimeType: z.string().describe('MIME type of the file'),
size: z.number().describe('File size in bytes'),
url: z.url().optional().describe('URL of the file (missing for files uploaded via older routes)'),
});
const testRequirementSchema = z.object({
id: z.string().describe('Requirement identifier'),
text: z.string().describe('Title of the requirement'),
url: z.url().or(z.literal('')).describe('URL of the requirement'),
});
const requirementIntegrationLinkSchema = z.object({
type: z.string().optional().describe('Integration type (e.g., "jira")'),
integrationId: z.string().optional().describe('ID of the integration'),
issueId: z.string().describe('Remote issue ID (e.g., "PROJ-123")'),
issueTitle: z.string().describe('Title of the remote issue'),
issueUrl: z.string().describe('Full URL of the remote issue'),
remoteLinkId: z.string().describe('Remote link ID assigned by the integration'),
});
const requirementListItemSchema = testRequirementSchema.extend({
integrationLink: requirementIntegrationLinkSchema
.nullable()
.describe('Integration link details (present only if the requirement is linked to Jira)'),
tcaseCount: z
.number()
.optional()
.describe('Number of test cases linked to this requirement (only when `tcaseCount` included)'),
});
const linkSchema = z.object({
text: z.string().describe('Title of the link'),
url: z.url().describe('URL of the link'),
});
const testFolderSchema = z.object({
projectId: z.string().describe('ID of the project the folder belongs to'),
id: z.number().describe('Unique identifier for the folder'),
parentId: z.number().describe('ID of the parent folder (0 for root folders)'),
pos: z.number().describe('Position of the folder among its siblings'),
title: z.string().describe('Name of the folder'),
comment: z.string().describe('Additional notes or description (HTML)'),
});
const testPreconditionSchema = z.object({
projectId: z.string().describe('Project id the precondition belongs to'),
id: z.number().describe('ID of the precondition'),
version: z.number().describe('Version of the precondition'),
isLatest: z.boolean().describe("Whether the precondition's version is the latest"),
type: z.string().describe('Type of the precondition. Known values: "standalone" | "shared"'),
title: z
.string()
.optional()
.describe('Title of the precondition (only for shared preconditions)'),
text: z.string().describe('Precondition text (HTML format)'),
createdAt: z.string().describe('Precondition creation time (ISO 8601)'),
updatedAt: z.string().describe('Precondition last-update time (ISO 8601)'),
deletedAt: z.string().optional().describe('Precondition deletion time (ISO 8601)'),
});
const testCustomFieldValueSchema = z.object({
value: z.string().describe('Current custom field value'),
isDefault: z.boolean().describe('Whether the value is the default set by the system'),
});
const customFieldOptionSchema = z.object({
id: z.string().describe('Option identifier'),
value: z.string().describe('Option display value'),
});
const customFieldSchema = z.object({
id: z.string().describe('Unique custom field identifier'),
type: z
.string()
.describe('Field type. Known values: "text" | "dropdown" | "checkbox" | "richtext"'),
systemName: z.string().describe('System identifier for the field (used in API requests)'),
name: z.string().describe('Display name of the field'),
required: z.boolean().describe('Whether the field is required for test cases'),
enabled: z.boolean().describe('Whether the field is currently enabled'),
options: z
.array(customFieldOptionSchema)
.nullable()
.describe('Available options (only for dropdown fields)'),
defaultValue: z.string().optional().describe('Default value for the field'),
pos: z.number().describe('Display position/order'),
allowAllProjects: z.boolean().describe('Whether the field is available to all projects'),
allowedProjectIds: z
.array(z.string())
.nullable()
.describe('List of project IDs if not available to all projects'),
createdAt: z.string().describe('ISO 8601 timestamp when the field was created'),
updatedAt: z.string().describe('ISO 8601 timestamp when the field was last updated'),
});
const testParameterValuesSchema = z.object({
tcaseId: z.string().describe('ID of the filled test case'),
tcaseVersion: z.number().describe('Version of the filled test case'),
values: z
.record(z.string(), z.string())
.describe('Parameter values substituted for this filled test case'),
});
// ---------- Tool input/output schemas ----------
export const getProjectInputSchema = z.object({
projectCode: projectCodeSchema,
});
export const getProjectOutputSchema = z.object({
id: z.string().describe('Unique identifier of the project'),
code: z.string().describe('Project code (e.g., BDI)'),
title: z.string().describe('Project title'),
overviewTitle: z.string().describe('Project overview title'),
overviewDescription: z.string().describe('Project overview description (HTML)'),
links: z.array(linkSchema).nullable().describe('Project links'),
createdAt: z.string().describe('Project creation time (ISO 8601)'),
updatedAt: z.string().describe('Project last-update time (ISO 8601)'),
archivedAt: z
.string()
.nullable()
.describe('Project archival time (ISO 8601); null if the project is not archived'),
});
export const listProjectsInputSchema = z.object({});
export const listProjectsOutputSchema = z.object({
projects: z.array(getProjectOutputSchema).nullable().describe('List of projects'),
});
export const getTestCaseInputSchema = z.object({
marker: testCaseMarkerSchema,
});
export const getTestCaseOutputSchema = z.object({
id: z.string().describe('Unique identifier of the test case'),
legacyId: z.string().describe('Legacy identifier of the test case (empty string if none is set)'),
version: z
.number()
.describe('Version of the test case. Updates (except folder/pos) create a new version'),
type: z
.string()
.describe('Type of the test case. Known values: "standalone" | "template" | "filled"'),
title: z.string().describe('Title of the test case'),
seq: z
.number()
.describe('Sequence number of the test case within its project (assigned incrementally)'),
folderId: z.number().describe('Identifier of the folder where the test case is placed'),
pos: z.number().describe('Ordered position (0-based) of the test case in its folder'),
priority: z
.string()
.describe('Priority of the test case. Known values: "high" | "medium" | "low"'),
comment: z
.string()
.describe('Test case precondition text (HTML). DEPRECATED — prefer the `precondition` object'),
precondition: testPreconditionSchema
.optional()
.describe('Test case precondition object (may be a shared or standalone precondition)'),
files: z.array(testFileSchema).nullable().describe('List of files attached to the test case'),
links: z.array(linkSchema).nullable().describe('Additional links relevant to the test case'),
authorId: z.number().describe('Unique identifier of the user who added the test case'),
isDraft: z.boolean().describe('Whether the test case is still in draft state'),
isLatestVersion: z.boolean().describe('Whether this is the latest version of the test case'),
isEmpty: z.boolean().describe('Whether the test case is empty (has no precondition and steps)'),
steps: z.array(testStepSchema).optional().describe('List of test case steps'),
tags: z.array(testTagSchema).optional().describe('List of test case tags'),
requirements: z.array(testRequirementSchema).optional().describe('Test case requirements'),
customFields: z
.record(z.string(), testCustomFieldValueSchema)
.optional()
.describe('Custom field values'),
templateTCaseId: z
.string()
.optional()
.describe('Corresponding template test case ID (only for filled test cases)'),
numFilledTCases: z
.number()
.optional()
.describe('Number of corresponding filled test cases (only for template test cases)'),
parameterValues: z
.array(testParameterValuesSchema)
.optional()
.describe('Parameter substitutions for filled test cases (only for template test cases)'),
filledTCaseTitleSuffixParams: z
.array(z.string())
.optional()
.describe('Parameter names whose substituted values are appended to each filled test case title for disambiguation (only for template test cases).'),
createdAt: z.string().describe('Test case creation time (ISO 8601)'),
updatedAt: z.string().describe('Test case last-update time (ISO 8601)'),
});
export const listTestCasesInputSchema = z.object({
projectCode: projectCodeSchema,
page: z.number().optional().describe('Page number for pagination'),
limit: z.number().optional().default(20).describe('Number of items per page'),
sortField: z
.enum([
'id',
'seq',
'folder_id',
'author_id',
'pos',
'title',
'priority',
'created_at',
'updated_at',
'legacy_id',
])
.optional()
.describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending). Requires sortField.'),
search: z.string().optional().describe('Search term to filter test cases'),
include: z
.array(z.enum([
'precondition',
'steps',
'tags',
'project',
'folder',
'path',
'requirements',
'customFields',
'parameterValues',
]))
.optional()
.describe('Related data to include in the response (not present by default)'),
folders: z.array(z.number()).optional().describe('Filter by folder IDs'),
tags: z.array(z.number()).optional().describe('Filter by tag IDs'),
types: z
.array(z.enum(['standalone', 'template', 'filled']))
.optional()
.describe('Filter by test case type'),
priorities: z
.array(z.enum(['high', 'medium', 'low']))
.optional()
.describe('Filter by priority levels'),
templateTCaseIds: z
.array(z.string())
.optional()
.describe('Filter filled test cases by their parent template test case IDs'),
requirementIds: z.array(z.string()).optional().describe('Filter by requirement IDs'),
customFields: z
.record(z.string(), z.array(z.string()))
.optional()
.describe('Filter by custom field values. Keys are custom field system names (without the `cf_` prefix); values are arrays of allowed values.'),
draft: z.boolean().optional().describe('Filter draft vs published test cases'),
});
export const listTestCasesOutputSchema = z.object({
total: z.number().describe('Total number of filtered test cases'),
page: z.number().describe('Current page number'),
limit: z.number().describe('Number of test cases per page'),
data: z
.array(getTestCaseOutputSchema.extend({
folder: testFolderSchema.optional().describe('Folder where the test case is placed'),
path: z.array(testFolderSchema).optional().describe('Folder path to the test case'),
project: getProjectOutputSchema.optional().describe('Project the test case belongs to'),
}))
.nullable()
.describe('List of test cases'),
});
export const createTestCaseInputSchema = z.object({
projectCode: projectCodeSchema,
title: z
.string()
.min(1, 'Title must be at least 1 character')
.max(511, 'Title must be at most 511 characters')
.describe('Test case title. In template test cases, may contain `${var}` placeholders that are substituted via `parameterValues` to produce filled test case titles.'),
type: z
.enum(['standalone', 'template'])
.describe("Type of test case. A 'template' test case uses `${var}` placeholders in its title, precondition, and step fields; each entry in `parameterValues` substitutes those placeholders to generate one filled test case."),
folderId: z
.number()
.int()
.positive('Folder ID must be a positive integer')
.describe('ID of the folder where the test case will be placed. Use upsert_folders tool to create new folders or get existing folders.'),
priority: z.enum(['high', 'medium', 'low']).describe('Test case priority'),
pos: z
.number()
.int()
.min(0, 'Position must be non-negative')
.optional()
.describe('Position within the folder (0-based index)'),
comment: z
.string()
.optional()
.describe('Test case precondition (HTML format). DEPRECATED — prefer the `precondition` object'),
precondition: testCasePreconditionInputSchema
.optional()
.describe('Test case precondition: either `{sharedPreconditionId}` to reference a shared one, or `{text}` for a standalone precondition'),
steps: z.array(testCaseStepInputSchema).optional().describe('List of test case steps'),
tags: z
.array(z.string().max(255, 'Tag title must be at most 255 characters'))
.optional()
.describe('List of tag titles'),
requirements: z
.array(testCaseRequirementInputSchema)
.optional()
.describe('Test case requirements'),
links: z
.array(testCaseLinkInputSchema)
.optional()
.describe('Additional links relevant to the test case'),
customFields: z
.record(z.string(), testCaseCustomFieldInputSchema)
.optional()
.describe('Custom field values. Keys are custom field system names. Custom fields must exist in the project (created via the web UI).'),
parameterValues: z
.array(z.object({
values: z
.record(z.string(), z.string())
.describe('Map of parameter name to substitution value. Keys are the names inside `${...}` placeholders (without the `${}` wrapper); values must be simple strings. Each entry in the outer array produces one filled test case.'),
}))
.optional()
.describe('One set of parameter substitutions per filled test case to generate from the template. Applies only when `type` is `template`; ignored for standalone test cases. A placeholder with no matching key is left untouched (e.g., `${action}` with no `action` value stays literal).'),
filledTCaseTitleSuffixParams: z
.array(z.string())
.optional()
.describe('Parameter names whose substituted values are appended to each filled test case title for disambiguation (e.g., pass `["env"]` to suffix titles with the `env` value).'),
isDraft: z.boolean().optional().default(false).describe('Whether to create as draft'),
});
export const createTestCaseOutputSchema = z.object({
id: z.string().describe('Unique identifier of the created test case'),
seq: z.number().describe('Sequence number of the test case in the project'),
});
export const updateTestCaseInputSchema = z.object({
projectCode: projectCodeSchema,
tcaseOrLegacyId: z
.string()
.describe('Test case identifier (can be one of test case UUID, sequence or legacy ID). Note: when the target is a `filled` test case, only `priority` can be updated directly; to change other fields, update the parent template or use `parameterValues`.'),
title: z
.string()
.min(1, 'Title must be at least 1 character')
.max(511, 'Title must be at most 511 characters')
.optional()
.describe('Test case title. In template test cases, may contain `${var}` placeholders that are substituted via `parameterValues` to produce filled test case titles.'),
priority: z.enum(['high', 'medium', 'low']).optional().describe('Test case priority'),
comment: z
.string()
.optional()
.describe('Test case precondition (HTML format). DEPRECATED — prefer the `precondition` object'),
precondition: testCasePreconditionInputSchema
.optional()
.describe('Test case precondition: either `{sharedPreconditionId}` to reference a shared one, or `{text}` for a standalone precondition'),
isDraft: z
.boolean()
.optional()
.describe('To publish a draft test case. A published test case cannot be converted to draft'),
steps: z.array(testCaseStepInputSchema).optional().describe('List of test case steps'),
tags: z
.array(z.string().max(255, 'Tag title must be at most 255 characters'))
.optional()
.describe('List of tag titles'),
requirements: z
.array(testCaseRequirementInputSchema)
.optional()
.describe('Test case requirements'),
links: z
.array(testCaseLinkInputSchema)
.optional()
.describe('Additional links relevant to the test case'),
customFields: z
.record(z.string(), testCaseCustomFieldInputSchema)
.optional()
.describe('Custom field values to update. Only the specified keys are modified; others remain unchanged.'),
parameterValues: z
.array(z.object({
tcaseId: z
.string()
.optional()
.describe('ID of an existing filled test case to update. Omit to generate a new filled test case from the template.'),
values: z
.record(z.string(), z.string())
.describe('Map of parameter name to substitution value. Keys are the names inside `${...}` placeholders (without the `${}` wrapper); values must be simple strings.'),
}))
.optional()
.describe('Full replacement of the template test case parameter values. Applies only when the target is a template test case: entries with `tcaseId` update the corresponding filled test case; entries without `tcaseId` generate a new one; any existing filled test case whose `tcaseId` is not present in this array will be deleted. To preserve existing filled test cases while appending, include their current `{tcaseId, values}` pairs.'),
filledTCaseTitleSuffixParams: z
.array(z.string())
.optional()
.describe('Parameter names whose substituted values are appended to each filled test case title for disambiguation (e.g., pass `["env"]` to suffix titles with the `env` value). Applies only to template test cases.'),
});
export const updateTestCaseOutputSchema = z.object({
message: z.string().describe('Success message'),
});
export const listFoldersInputSchema = z.object({
projectCode: projectCodeSchema,
page: z.number().optional().describe('Page number for pagination'),
limit: z.number().optional().default(100).describe('Number of items per page'),
sortField: z
.enum(['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at'])
.optional()
.describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending)'),
});
export const listFoldersOutputSchema = z.object({
total: z.number().describe('Total number of items available'),
page: z.number().describe('Current page number'),
limit: z.number().describe('Number of items per page'),
data: z.array(testFolderSchema).nullable().describe('List of folders'),
});
export const upsertFoldersInputSchema = z.object({
projectCode: projectCodeSchema,
folders: z
.array(z.object({
path: z
.array(z.string().min(1).max(255))
.min(1)
.describe('Array of folder names representing the hierarchy'),
comment: z
.string()
.nullable()
.describe('Additional notes or description for the leaf folder (HTML format). Set null to keep existing comment of an existing folder.'),
}))
.min(1)
.describe('Array of folder requests to create or update'),
});
export const upsertFoldersOutputSchema = z.object({
ids: z
.array(z.array(z.number()))
.nullable()
.describe('One folder-ID array per input folder, representing the full path hierarchy'),
});
export const listTestCasesTagsInputSchema = z.object({
projectCode: projectCodeSchema,
sortField: z.enum(['created_at', 'title']).optional().describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending). Requires sortField.'),
include: z
.enum(['tcaseCount'])
.optional()
.describe('Include optional fields like the number of test cases using each tag'),
});
export const listTestCasesTagsOutputSchema = z.object({
tags: z
.array(testTagSchema.extend({
tcaseCount: z
.number()
.optional()
.describe('Number of test cases using this tag (only when `tcaseCount` is included)'),
}))
.nullable()
.describe('List of tags defined in the project'),
});
export const listCustomFieldsInputSchema = z.object({
projectCode: projectCodeSchema,
});
export const listCustomFieldsOutputSchema = z.object({
customFields: z
.array(customFieldSchema)
.nullable()
.describe('List of custom fields defined in the project'),
});
export const listRequirementsInputSchema = z.object({
projectCode: projectCodeSchema,
sortField: z.enum(['created_at', 'text']).optional().describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending). Requires sortField.'),
include: z
.enum(['tcaseCount'])
.optional()
.describe('Include optional fields like the number of test cases linked to each requirement'),
});
export const listRequirementsOutputSchema = z.object({
requirements: z
.array(requirementListItemSchema)
.nullable()
.describe('List of requirements defined in the project'),
});
export const getSharedPreconditionInputSchema = z.object({
projectCode: projectCodeSchema,
sharedPreconditionId: z
.number()
.int()
.positive('Shared precondition ID must be a positive integer')
.describe('Identifier of the shared precondition'),
});
export const getSharedPreconditionOutputSchema = testPreconditionSchema;
export const listSharedPreconditionsInputSchema = z.object({
projectCode: projectCodeSchema,
sortField: z.enum(['created_at', 'title']).optional().describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending). Requires sortField.'),
include: z
.enum(['tcaseCount'])
.optional()
.describe('Include optional fields like the number of test cases referencing each precondition'),
});
export const listSharedPreconditionsOutputSchema = z.object({
sharedPreconditions: z
.array(getSharedPreconditionOutputSchema.extend({
tcaseCount: z
.number()
.optional()
.describe('Number of test cases using this shared precondition (only when `tcaseCount` is included)'),
}))
.nullable()
.describe('List of shared preconditions defined in the project'),
});
export const getSharedStepInputSchema = z.object({
projectCode: projectCodeSchema,
sharedStepId: z
.number()
.int()
.positive('Shared step ID must be a positive integer')
.describe('Identifier of the shared step'),
});
export const getSharedStepOutputSchema = testStepSchema;
export const listSharedStepsInputSchema = z.object({
projectCode: projectCodeSchema,
sortField: z.enum(['created_at', 'title']).optional().describe('Field to sort results by'),
sortOrder: z
.enum(['asc', 'desc'])
.optional()
.describe('Sort direction (ascending or descending). Requires sortField.'),
include: z
.enum(['tcaseCount'])
.optional()
.describe('Include optional fields like the number of test cases referencing each shared step'),
});
export const listSharedStepsOutputSchema = z.object({
sharedSteps: z
.array(z.object({
...testStepSchemaShape,
tcaseCount: z
.number()
.optional()
.describe('Number of test cases using this shared step (only when `tcaseCount` is included)'),
}))
.nullable()
.describe('List of shared steps defined in the project'),
});