zephyr-scale-mcp
MCP server for Zephyr Scale on self-hosted Jira Server / Data Center (formerly TM4J). It gives an AI agent — Claude Code, Claude Desktop, Cursor, any MCP client — 54 tools over your test management: test cases, folders, test cycles, executions, test plans, attachments and automation imports, through the Zephyr Scale REST API v1 at {JIRA_BASE_URL}/rest/atm/1.0.

npm: zephyr-scale-mcp · MCP Registry: io.github.vilaabo/zephyr-scale-mcp · Русская версия →
⚠️ Scope: Server / Data Center only. Zephyr Scale Cloud (API v2) and Zephyr Squad are different APIs and are out of scope — this server will not work against them.
Why this exists
Most Zephyr MCP servers target the Cloud API. If your Jira is on-premise you get the v1 API instead, and v1 has teeth: test runs are immutable after creation, folders cannot be listed, statuses are case-sensitive internal names, BDD scripts reject a Feature: header, and older plugin builds are missing whole endpoints. This server encodes that knowledge instead of leaving the agent to discover it one 400 at a time.
- 54 tools (42 public + 11 opt-in internal-API tools), each with a description that states the endpoint, the constraints a caller cannot guess, and the exact response shape.
- Composite tools for the API's blind spots —
add_test_steps merges steps by id (read → merge → write) so nothing is silently deleted; recreate_test_run_with_items works around run immutability and can carry the last results over; get_issue_test_coverage, get_test_run_summary, clone_test_case, move_test_cases_to_folder.
- Degradation on older builds — automatic fallback when the paginated results endpoint is absent, and a per-item fallback when the bulk create endpoint is broken. Both were found on a real legacy instance, not in a changelog.
- Plumbing that fails loudly — strict zod input validation,
Retry-After-aware retries with exponential backoff, error messages carrying actionable hints, read-only mode, secrets kept out of logs and tool output.
- 1177 unit and contract tests (vitest + msw, no network) plus a gated end-to-end scenario against a real instance.
Quick start
Requirements: Node.js >= 20, Jira Server/DC with the Zephyr Scale plugin, and a Personal Access Token (Jira 8.14+) or a username/password pair.
Nothing to clone — the package is on npm. Claude Code:
claude mcp add zephyr-scale \
--env JIRA_BASE_URL=https://jira.example.com \
--env JIRA_PAT=<personal access token> \
--env ZEPHYR_DEFAULT_PROJECT_KEY=PROJ \
--env ZEPHYR_ALLOW_INTERNAL_API=true \
-- npx -y zephyr-scale-mcp
Claude Desktop / any MCP client (claude_desktop_config.json, .mcp.json):
{
"mcpServers": {
"zephyr-scale": {
"command": "npx",
"args": ["-y", "zephyr-scale-mcp"],
"env": {
"JIRA_BASE_URL": "https://jira.example.com",
"JIRA_PAT": "<personal access token>",
"ZEPHYR_DEFAULT_PROJECT_KEY": "PROJ",
"ZEPHYR_ALLOW_INTERNAL_API": "true"
}
}
}
}
Running from source instead
git clone https://github.com/vilaabo/zephyr-scale-mcp.git
cd zephyr-scale-mcp
npm install
npm run build
Then point the client at the build: "command": "node", "args": ["/path/to/zephyr-scale-mcp/dist/index.js"].
ZEPHYR_ALLOW_INTERNAL_API=true is optional and recommended. It registers 12 extra tools that reach the things the public API cannot do at all: editing a test run in place — renaming or moving it (update_test_run) and adding or removing cases without changing its key (add_test_cases_to_run, remove_test_cases_from_run) — plus listing the folder tree (get_folder_tree), deleting folders (delete_folder), editing older executions (update_test_result_by_id) and reading the exact status names the API silently expects (get_status_options). These call the same undocumented /rest/tests/1.0 endpoints the Jira UI itself uses; the vendor does not support them and they may differ or be absent on another Zephyr Scale version. Leave the flag off if that trade-off is not acceptable — the other 42 tools are unaffected.
Then ask the agent to run health_check. It verifies connectivity and credentials via GET /rest/api/2/myself and, when ZEPHYR_DEFAULT_PROJECT_KEY is set, that the Zephyr plugin answers on /rest/atm/1.0.
What you can ask your agent to do
- "Create the folder
/Regression/Payments and add step-by-step test cases for the checkout flow described in this document."
- "Find every Draft case in
/Regression, review them, and set the ready ones to Approved."
- "Create a cycle for sprint 42 with all smoke cases, then record the results from this report — step by step where the script has steps."
- "Which test cases cover PROJ-123, and when did each of them last pass?" — traceability from the issue to its cases and their latest executions.
- "Take this ZIP of Cucumber JSON reports and publish it as a new cycle in PROJ." — automation import; scenarios are matched to BDD cases by their
@TestCaseKey=PROJ-T1 tag.
- "Add two steps to PROJ-T55 after step 3." — existing steps and their ids survive.
- "Recreate cycle PROJ-R7 with three more cases, keep the results, and delete the original."
Configuration
JIRA_BASE_URL | yes | — | Jira base URL without a trailing /, e.g. https://jira.example.com |
JIRA_AUTH | no | pat | pat | basic |
JIRA_PAT | with pat | — | Jira Server/DC Personal Access Token |
JIRA_USERNAME, JIRA_PASSWORD | with basic | — | Basic-auth credentials |
JIRA_TIMEOUT_MS | no | 30000 | Per-request timeout |
JIRA_MAX_RETRIES | no | 2 | Retries for GET and for any 429/503, honoring Retry-After, otherwise exponential backoff with jitter |
JIRA_TLS_REJECT_UNAUTHORIZED | no | true | false accepts self-signed certificates — this disables TLS verification process-wide and prints a warning to stderr |
ZEPHYR_DEFAULT_PROJECT_KEY | no | — | Used whenever a tool is called without projectKey |
ZEPHYR_READONLY | no | false | true makes every write tool refuse with an error; read tools keep working |
ZEPHYR_ALLOW_INTERNAL_API | no | false | true registers the 11 UNOFFICIAL tools backed by the internal /rest/tests/1.0 API |
ZEPHYR_LOG_LEVEL | no | info | debug | info | warn | error |
Two guarantees the tests cover: JIRA_PAT and JIRA_PASSWORD never appear in logs, tool output or error messages — in any encoding they can take on the way out: raw, JSON-escaped, or the base64 basic-auth token (error text carries the method and path only — never the query string, which may contain data) — and stdout is reserved for the MCP protocol, every log line goes to stderr. One deliberate exception: a secret shorter than six characters is left alone, because redacting it would corrupt unrelated output while protecting nothing; the server warns about it at startup.
Configuration is validated at startup: all problems are reported at once and the process exits with a non-zero code rather than starting half-configured.
Tools
54 tools. The 42 public ones are always registered; the 11 in the last group only with ZEPHYR_ALLOW_INTERNAL_API=true.
Test cases — 13 tools
create_test_case | Create a case with a STEP_BY_STEP / PLAIN_TEXT / BDD script, parameters, custom fields, Call-to-Test steps |
get_test_case | Read a case, optionally narrowed by fields; step ids come back here |
search_test_cases | TQL search with pagination; a query longer than 1500 characters is sent as POST /testcase/search (which supports only projectKey, key, name) |
update_test_case | Partial update; testScript.steps is synchronized by id (see limitation 7) |
add_test_steps | Insert steps at a position without losing the existing ones — read, merge by id, write back |
set_test_script | Replace the whole script or change its format; destructive by design |
clone_test_case | Copy a case inside its project with fresh step ids; links, attachments and history are not copied |
move_test_cases_to_folder | Bulk-move by explicit keys or by source folder; a failing case does not abort the rest |
delete_test_case | Permanent delete of the case, its script and its history |
create_test_cases_bulk | Create many cases in one call, with a per-case fallback on broken bulk endpoints |
link_issues_to_test_cases | Bulk-link cases to Jira issues (additive) |
get_test_cases_linked_to_issue | Reverse lookup: issue → cases |
get_issue_test_coverage | Traceability report: issue → linked cases → latest execution of each |
Test cycles (test runs) — 7 tools
create_test_run | Create a cycle with its complete item list — optionally with each item's execution result in the same call |
get_test_run | Read a cycle including its items |
search_test_runs | TQL search — for runs only projectKey and folder are searchable |
delete_test_run | Permanent delete of the cycle and all its results |
get_test_run_results | Page through the executions of a cycle, with the legacy flat-endpoint fallback |
get_test_run_summary | Last execution per item: byStatus counts verbatim, executionProgressPct, passRatePct when a literal Pass status exists |
recreate_test_run_with_items | The public workaround for run immutability: rebuild under a new key with cases added or removed, optionally carrying the last results over and deleting the original |
Executions (test results) — 4 tools
create_test_result | Append a new execution to a run item, including per-step scriptResults |
update_last_test_result | Partial update of the most recent execution of an item |
create_test_results_bulk | Many executions for one cycle in a single call |
get_latest_result_for_test_case | The execution of a case with the greatest stored execution date, across all cycles — not necessarily the one recorded last |
Items that exist several times in a run (per environment or per assignee) are disambiguated with matchEnvironment / matchUserKey, sent as query parameters.
Test plans — 5 tools
create_test_plan | Create a plan; returns { key }, e.g. PROJ-P123 |
get_test_plan | Read a plan with its linked runs and issues |
update_test_plan | Partial update |
delete_test_plan | Permanent delete |
search_test_plans | TQL search; the searchable field set varies by Zephyr Scale version |
Folders — 2 tools
create_folder | Create a case / plan / cycle folder from a full path; with recursive (default true) a 400 on the full path triggers creating each parent prefix and one retry — 403, 409 and 5xx propagate untouched |
rename_folder | Rename one folder segment by its numeric id (and optionally set its custom fields) |
Attachments — 4 tools
upload_attachment | Attach a local file to a case, a case step, a cycle, a result or a result step (multipart) |
list_attachments | List the attachments of any of those targets; each record carries the id and url the other tools need |
download_attachment | Save an attachment by id or by the url list_attachments returned — that url must be on the configured Jira host. The only public tool that reads from the internal API: attachment content is served by /rest/tests/1.0/attachment/{id}, which exists regardless of ZEPHYR_ALLOW_INTERNAL_API |
delete_attachment | Permanently delete one attachment by numeric id |
Automation — 3 tools
upload_automation_results | Publish a ZIP of results in Zephyr's custom JSON format; always creates a new cycle |
upload_cucumber_results | Publish a ZIP of Cucumber JSON reports; scenarios are matched by their @TestCaseKey=PROJ-T1 tag |
download_feature_files | Export BDD cases as a ZIP of .feature files; tql is required and uses the testCase.-prefixed dialect. The archive is written only after its PK signature is verified, so an HTML login page served with HTTP 200 fails instead of leaving a corrupt file |
Service and diagnostics — 4 tools
health_check | Jira reachability, credentials, and whether the Zephyr plugin answers |
list_environments | The project's environments — the exact case-sensitive names results reference |
create_environment | Create an environment in the project |
find_jira_user | Resolve the Jira user key (JIRAUSER10000) that owner / executedBy / assignedTo require |
UNOFFICIAL — internal API, opt-in — 11 tools
Registered only with ZEPHYR_ALLOW_INTERNAL_API=true. These call /rest/tests/1.0, the undocumented API behind the Jira UI. The vendor does not support it: endpoints may differ or be absent on another Zephyr Scale version, and a 404/405 from one of these tools means exactly that. Every request shape below was either captured from the Jira UI's own traffic or verified live against a real instance — never guessed. Errors from this layer carry a hint saying so.
update_test_run | Rename a cycle, move it to another folder (by numeric folder id) or change its planned dates in place, keeping its key, items and results. No PUT /testrun exists in the public API |
add_test_cases_to_run | Append cases to an existing cycle in place; the key and the existing results survive |
remove_test_cases_from_run | Remove items from an existing cycle in place — their whole execution history dies with them |
reorder_test_run_items | Reorder the items of an existing cycle; a cycle already in the requested order makes no write at all |
link_issues_to_test_run | Link Jira issues to an existing cycle — the public API rejects an issueLinks field on runs outright, so this is the only way |
link_test_run_to_plan | Associate an existing cycle with a test plan after creation (the public API accepts testPlanKey only at creation) |
delete_test_results | Delete individual executions by numeric id. The last execution of an item cannot be deleted |
update_test_result_by_id | Edit any execution in an item's history, including older ones; status is resolved from its case-sensitive name to the internal id |
get_folder_tree | The full folder tree of a project with the numeric ids rename_folder, delete_folder and update_test_run need. Each entity type has its own tree |
get_status_options | The exact internal names of the project's execution statuses, case statuses or priorities — the values the public API silently ignores when they are wrong |
get_custom_field_definitions | Custom field definitions per entity type: names, types, required flags, options |
delete_folder | Delete a folder by numeric id. What happens to a non-empty folder is version-specific — empty it first |
Working around API v1
These are the constraints the server is built around. Every tool description repeats the ones relevant to it, so the agent sees them at call time.
- Test runs are immutable. There is no
PUT /testrun: a run cannot be renamed, moved, or have cases added or removed. Its items are fixed at creation and the run status is derived from item statuses. Escape hatches: recreate_test_run_with_items (public, produces a new key) or the internal update_test_run / add_test_cases_to_run / remove_test_cases_from_run (same key).
- Folders are never created implicitly.
create_test_case, create_test_run and create_test_plan fail with 400 on an unknown folder. Folders also cannot be listed through the public API, and renaming needs the numeric id returned by create_folder — or get_folder_tree with the internal API enabled.
owner / executedBy / assignedTo take a Jira user key (JIRAUSER10000), never a username or an e-mail. find_jira_user resolves it.
- TQL is strict: spaces around operators are mandatory, string values go in double quotes,
AND is the only connector (no OR), and folder paths start with /. Test runs are searchable by projectKey and folder only. For single/multi-choice custom fields = does not work — use IN.
- Statuses, priorities and environments are case-sensitive internal names. Built-in statuses are shown localized in the UI (the label you see for
Draft may be a translation) while custom ones use their literal names — get_status_options shows what the API actually expects. A wrong execution status is silently ignored by the public API.
- A BDD script is the scenario body only — bare
Given / When / Then / And / But lines. Text wrapped in Feature: / Scenario: is rejected with 400 Invalid BDD Script; the wrapper is generated on export.
STEP_BY_STEP steps are synchronized by id on PUT: a step without an id is created, a step with an id is updated, and every stored step missing from the list is deleted. Always send the complete final list with ids carried over from get_test_case — or let add_test_steps do the read-merge-write.
- Deprecated fields are intentionally not accepted: use
issueLinks instead of issueKey, actualEndDate instead of executionDate, executedBy instead of userKey.
Quirks of older plugin builds
All of the following was observed live on a real legacy Zephyr Scale Server instance and is covered by tests.
- Cycle keys may use the
-C prefix (PROJ-C34) instead of -R. Every run-key parameter takes the key as-is, so pass whatever your instance shows.
GET /testrun/{key}/testresults/page may not exist. get_test_run_results and get_test_run_summary fall back to the deprecated flat endpoint and paginate client-side, adding a note to the response. A run that genuinely does not exist still surfaces as a 404.
- An overall
status sent together with scriptResults is ignored. Send the step results first, then set the overall status with update_last_test_result.
POST /testcase/link-issues may answer 500. Link through update_test_case with issueLinks instead.
POST /testcase/bulk may answer HTTP 500 with an empty body for any payload while single creation works. create_test_cases_bulk falls back to creating the cases one by one — on any 5xx, and on a JSON 404 (which means "no such endpoint on this build", unlike the HTML 404 Jira serves when the plugin is absent) — and reports which ones succeeded (#1). Every other 4xx is a payload error and is not retried.
- Posting a result for a case that is not among the run's items silently adds it to the run on this build; other builds reject the call with 400/404. The result tools document both.
- The custom automation-results format is validated strictly.
{"version": 1, "executions": [{"source", "result", "testCase": {"key"}}]} works; extra per-execution fields such as executionTime are rejected with Invalid Custom Format JSON file. Cucumber JSON reports work as-is when the scenario carries @TestCaseKey=PROJ-T1.
download_feature_files requires the tql query parameter — the API rejects the call without it.
- The last execution of a run item cannot be deleted.
delete_test_results rejects it; remove the item instead.
- Custom fields may be absent entirely — the definitions endpoint then returns
[].
Development
npm run typecheck
npm test
npm run build
npm run smoke
The smoke scenario is skipped unless ZEPHYR_E2E=1. It needs a real JIRA_BASE_URL, credentials and a dedicated ZEPHYR_DEFAULT_PROJECT_KEY — it creates and deletes real entities and leaves /mcp-smoke-* folders behind, since the public API cannot delete folders.
src/
├── index.ts # bootstrap: config, tool registration, stdio transport
├── config.ts # environment validation
├── http.ts # fetch wrapper: auth, timeouts, retries, error normalization,
│ # multipart and binary bodies
├── schemas.ts # shared parameter schemas, field shapes, recurring description constants
├── toolkit.ts # defineTool(): strict zod input, read-only guard,
│ # JSON / isError response shaping, shared helpers
├── internal.ts # the UNOFFICIAL /rest/tests/1.0 layer: key -> id resolution,
│ # run-item plumbing, status resolution, error hints
├── runResults.ts # paged run results with the legacy fallback
├── log.ts # leveled logger, stderr only
└── tools/ # one module per tool family: testCases, testRuns, testResults,
# testPlans, folders, attachments, automation, misc,
# runMaintenance, resultsMaintenance, internalRefs
test/ # one test module per source module + negative contract tests
# and the gated smoke scenario
Versioning and changelog
Version 1.0.0. Tool names, parameter names and response shapes are a public contract from this release on and change only in a major version. See CHANGELOG.md.
License
MIT