From 134d86665c449072b00c6eaa7124c5cd9c0daa72 Mon Sep 17 00:00:00 2001 From: Eddy Mhalli Date: Mon, 13 Apr 2026 08:02:47 +0200 Subject: [PATCH 1/3] Add custom fields support, list_fields tool, and additional getters - Add testmo_list_fields tool for listing custom fields per project - Add custom field support (custom_preconditions, custom_steps, custom_expected) to testmo_update_case with additionalProperties passthrough - Bypass SDK constructFromObject in create/update case handlers to preserve custom fields - Pass additional fields (template_id, state_id, estimate) in single-case creation - Improve error handling to extract body/status from API errors - Update README to document all new tools (fields, get_case, get_folder, get_automation_source, get_group, get_role, get_user) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 31 +++++++++++++++++++++----- index.js | 66 +++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index cc8b94b..20dc6a4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e - [Automation Runs](#automation-runs) - [Test Cases (repository)](#test-cases-repository) - [Folders (repository)](#folders-repository) + - [Fields](#fields) - [Sessions](#sessions) + - [Groups](#groups) + - [Roles](#roles) - [Users](#users) - [Status IDs Reference](#status-ids-reference) - [Example prompts](#example-prompts) @@ -32,11 +35,14 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e | **Projects** | List projects, get project details | | **Milestones** | List milestones, get milestone details | | **Test Runs** | List runs, get run details, list run results | -| **Automation Runs** | List, get, create automation runs; submit results; mark complete | -| **Test Cases** | List, create, update, delete repository cases | -| **Folders** | List, create, update, delete repository folders | +| **Automation Runs** | Get automation sources; list, get, create automation runs; submit results; mark complete | +| **Test Cases** | List, get, create, update (with custom fields), delete repository cases | +| **Folders** | List, get, create, update, delete repository folders | +| **Fields** | List custom fields and options for a project | | **Sessions** | List and get exploratory test sessions | -| **Users** | Get current user, list all users | +| **Groups** | Get group details | +| **Roles** | Get role details | +| **Users** | Get current user, list all users, get user by ID | ## Requirements @@ -119,6 +125,7 @@ npm start - **`testmo_list_run_results`** — Get results for a run (filters: status, user, date range, expands) ### Automation Runs +- **`testmo_get_automation_source`** — Get a single automation source by ID - **`testmo_list_automation_runs`** — List automation runs (optional source filter) - **`testmo_get_automation_run`** — Get an automation run by ID - **`testmo_create_automation_run`** — Create a new automation run (name, source, optional milestone) @@ -127,23 +134,35 @@ npm start ### Test Cases (repository) - **`testmo_list_cases`** — List cases (filter by folder, template, date) -- **`testmo_create_case`** — Create one or more cases (`cases` array or single `name` + optional `folder_id`) -- **`testmo_update_case`** — Update cases by `project_id` + `ids` and optional fields +- **`testmo_get_case`** — Get a single case by ID (with optional expands: history, comments, tags, etc.) +- **`testmo_create_case`** — Create one or more cases (`cases` array or single `name` + optional `folder_id`, `template_id`, `state_id`, `estimate`) +- **`testmo_update_case`** — Update cases by `project_id` + `ids`; supports custom fields (`custom_preconditions`, `custom_steps`, `custom_expected`, etc.) - **`testmo_delete_case`** — Delete cases by `project_id` + `ids` ### Folders (repository) - **`testmo_list_folders`** — List folders in a project +- **`testmo_get_folder`** — Get a single folder by ID - **`testmo_create_folder`** — Create one or more folders (`folders` array or single `name` + optional `parent_id`) - **`testmo_update_folder`** — Update folders by `project_id` + `ids` - **`testmo_delete_folder`** — Delete folders by `project_id` + `ids` +### Fields +- **`testmo_list_fields`** — List custom fields and their options for a project (optional entity filter: `cases`, `runs`, etc.) + ### Sessions - **`testmo_list_sessions`** — List exploratory sessions for a project - **`testmo_get_session`** — Get session details +### Groups +- **`testmo_get_group`** — Get a single group by ID + +### Roles +- **`testmo_get_role`** — Get a single role by ID + ### Users - **`testmo_get_current_user`** — Get the current user's profile - **`testmo_list_users`** — List all users in the instance +- **`testmo_get_user`** — Get a single user by ID ## Status IDs Reference diff --git a/index.js b/index.js index c5d9325..b8c7671 100644 --- a/index.js +++ b/index.js @@ -247,7 +247,8 @@ const TOOLS = [ }, { name: "testmo_update_case", - description: "Update one or more repository test cases", + description: + "Update one or more repository test cases. Supports custom fields (custom_preconditions, custom_steps, custom_expected, etc.)", inputSchema: { type: "object", properties: { @@ -258,8 +259,12 @@ const TOOLS = [ state_id: { type: "number" }, status_id: { type: "number" }, estimate: { type: "number" }, + custom_preconditions: { type: "string", description: "Preconditions (HTML)" }, + custom_steps: { type: "array", description: "Steps array with step/expected objects" }, + custom_expected: { type: "string", description: "Expected result (HTML)" }, }, required: ["project_id", "ids"], + additionalProperties: true, }, }, { @@ -371,6 +376,22 @@ const TOOLS = [ }, }, + // ─ Fields ───────────────────────────────────────────────────────────────── + { + name: "testmo_list_fields", + description: "List custom fields and their options for a project", + inputSchema: { + type: "object", + properties: { + project_id: { type: "number", description: "The project ID" }, + entity: { type: "string", description: "Filter by entity type (e.g. 'cases', 'runs')" }, + page: { type: "number" }, + per_page: { type: "number" }, + }, + required: ["project_id"], + }, + }, + // ─ Sessions ─────────────────────────────────────────────────────────────── { name: "testmo_list_sessions", @@ -480,6 +501,7 @@ function createTestmoApis(instanceUrl, token) { folders: new testmo.FoldersApi(client), groups: new testmo.GroupsApi(client), roles: new testmo.RolesApi(client), + fields: new testmo.FieldsApi(client), }; } @@ -650,23 +672,26 @@ async function handleTool(apis, name, args) { const casesPayload = Array.isArray(a.cases) ? a.cases : a.name - ? [{ name: a.name, folder_id: a.folder_id }] + ? [ + { + name: a.name, + folder_id: a.folder_id, + template_id: a.template_id, + state_id: a.state_id, + estimate: a.estimate, + }, + ] : []; if (casesPayload.length === 0) throw new Error("Provide either 'cases' array or 'name' for a single case"); - const createCase = testmo.CreateRepositoryCase.constructFromObject({ cases: casesPayload }); - return apis.repositoryCases.createCases(a.project_id, createCase); + // Bypass SDK constructFromObject to preserve custom fields (custom_*) + return apis.repositoryCases.createCases(a.project_id, { cases: casesPayload }); } case "testmo_update_case": { - const updateCase = testmo.UpdateRepositoryCase.constructFromObject({ - ids: a.ids, - name: a.name, - folder_id: a.folder_id, - state_id: a.state_id, - status_id: a.status_id, - estimate: a.estimate, - }); - return apis.repositoryCases.updateCases(a.project_id, updateCase); + // Bypass SDK constructFromObject to preserve custom fields (custom_*) + const payload = { ...a }; + delete payload.project_id; + return apis.repositoryCases.updateCases(a.project_id, payload); } case "testmo_delete_case": { const deleteCase = testmo.DeleteRepositoryCases.constructFromObject({ ids: a.ids }); @@ -726,6 +751,14 @@ async function handleTool(apis, name, args) { return { deleted: a.ids.length }; } + // Fields + case "testmo_list_fields": + return apis.fields.getFieldPage(a.project_id, { + page: a.page, + perPage: a.per_page, + entity: a.entity, + }); + // Sessions case "testmo_list_sessions": return apis.sessions.getSessionPage(a.project_id, { @@ -819,8 +852,13 @@ async function handleRequest(req) { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }); } catch (err) { + const msg = + err.message || + (err.body && JSON.stringify(err.body)) || + (err.status && `HTTP ${err.status}: ${err.statusText}`) || + JSON.stringify(err); sendResponse(id, { - content: [{ type: "text", text: `Error: ${err.message}` }], + content: [{ type: "text", text: `Error: ${msg}` }], isError: true, }); } From 118c67e9ac8e5b154a33c24569f0c3093758128b Mon Sep 17 00:00:00 2001 From: Eddy Mhalli Date: Fri, 31 Jul 2026 11:09:54 +0200 Subject: [PATCH 2/3] Support native issue links and array fields on repository cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare `issues` and `tags` as arrays in the testmo_update_case and testmo_create_case input schemas. Undeclared fields are serialized as strings by the MCP client, so passing issue links previously failed with "The issues field is not of type array" — the same class of bug already handled for custom_* via coerceCustomValue. Generalize that coercion with an ARRAY_FIELDS set so a stringified array is still recovered. Payload per the SDK's UpdateRepositoryCase model: an array of issue ids or of {display_id, integration_id, connection_project_id} objects. Note that Testmo replaces the case's whole issue list, so callers must merge. Also included (pre-existing work on this branch): custom_* field coercion, raw-HTTP get_case to bypass SDK model deserialization stripping custom fields, and API client exposure. Verified end-to-end against project 2: 19 cases linked across 4 Jira keys, with existing links preserved on the 4 cases that already had them. --- index.js | 91 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/index.js b/index.js index b8c7671..b72f273 100644 --- a/index.js +++ b/index.js @@ -239,10 +239,19 @@ const TOOLS = [ estimate: { type: "number" }, }, required: ["name"], + additionalProperties: true, }, }, + issues: { + type: "array", + description: + "Linked issues for the single-case path. Array of issue IDs (integers) or objects " + + '{display_id, integration_id, connection_project_id} — e.g. [{"display_id":"IM-31082","integration_id":1}].', + }, + tags: { type: "array", description: "Tags for the single-case path" }, }, required: ["project_id"], + additionalProperties: true, }, }, { @@ -262,6 +271,15 @@ const TOOLS = [ custom_preconditions: { type: "string", description: "Preconditions (HTML)" }, custom_steps: { type: "array", description: "Steps array with step/expected objects" }, custom_expected: { type: "string", description: "Expected result (HTML)" }, + issues: { + type: "array", + description: + "Linked issues (native tracker integration). Array of issue IDs (integers) or objects " + + "{display_id, integration_id, connection_project_id} — e.g. " + + '[{"display_id":"IM-31082","integration_id":1}]. Existing issues are matched, new ones created. ' + + "Replaces the case's current issue list, so include the existing links you want to keep.", + }, + tags: { type: "array", description: "Tags to set on the case" }, }, required: ["project_id", "ids"], additionalProperties: true, @@ -488,6 +506,7 @@ function createTestmoApis(instanceUrl, token) { client.authentications.bearerAuth.accessToken = token; return { + client, projects: new testmo.ProjectsApi(client), milestones: new testmo.MilestonesApi(client), runs: new testmo.RunsApi(client), @@ -529,6 +548,41 @@ function slugForKey(name) { return s || `test_${Math.random().toString(36).slice(2, 10)}`; } +// The MCP client serializes schema-untyped fields as strings (only the fields +// explicitly typed in a tool's inputSchema keep their JSON type). Testmo's custom +// dropdown/multiselect fields therefore arrive as "2" or "[103]" and get rejected +// ("must be a number" / "not of type array"). Reverse that for every custom_* key. +function coerceCustomValue(value) { + if (typeof value !== "string") return value; + const s = value.trim(); + if (/^-?\d+$/.test(s)) return Number(s); + if (s === "true" || s === "false") return s === "true"; + if (s.startsWith("[") || s.startsWith("{")) { + try { + return JSON.parse(s); + } catch { + return value; + } + } + return value; // HTML text fields (preconditions/expected) start with "<" — left intact +} + +// Non-custom fields that are arrays on the wire. They are declared in the tool +// inputSchemas so a well-behaved client keeps their JSON type, but coerce them +// anyway: a client that stringifies them would otherwise trip Testmo's +// "field is not of type array" validation with no useful hint. +const ARRAY_FIELDS = new Set(["issues", "tags"]); + +function coerceCustomFields(obj) { + if (!obj || typeof obj !== "object") return obj; + for (const key of Object.keys(obj)) { + if (key.startsWith("custom_") || ARRAY_FIELDS.has(key)) { + obj[key] = coerceCustomValue(obj[key]); + } + } + return obj; +} + // ── Tool Handlers ───────────────────────────────────────────────────────────── async function handleTool(apis, name, args) { @@ -642,23 +696,24 @@ async function handleTool(apis, name, args) { // Repository cases case "testmo_get_case": { const caseId = a.case_id; + // Raw HTTP call to bypass SDK model deserialization (which strips custom fields) + const baseUrl = apis.client.basePath; + const token = apis.client.authentications.bearerAuth.accessToken; let page = 1; - const perPage = 100; for (;;) { - const pageData = await apis.repositoryCases.getCasesPage(a.project_id, { - page, - perPage, - sort: "repository_cases:id", - order: "asc", - expands: a.expands, + const url = `${baseUrl}/api/v1/projects/${a.project_id}/cases?page=${page}&per_page=25&sort=repository_cases%3Aid&order=asc`; + const resp = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); + const pageData = await resp.json(); const list = pageData?.result ?? []; const found = list.find((c) => c.id === caseId || c.key === caseId); if (found) return found; - if (list.length < perPage || page >= (pageData?.last_page ?? page)) break; + if (list.length < 25 || page >= (pageData?.last_page ?? page)) break; page += 1; } - throw new Error(`Case with id ${caseId} not found in project ${a.project_id}`); + throw new Error(`Case ${caseId} not found in project ${a.project_id}`); } case "testmo_list_cases": return apis.repositoryCases.getCasesPage(a.project_id, { @@ -669,28 +724,20 @@ async function handleTool(apis, name, args) { perPage: a.per_page, }); case "testmo_create_case": { - const casesPayload = Array.isArray(a.cases) - ? a.cases - : a.name - ? [ - { - name: a.name, - folder_id: a.folder_id, - template_id: a.template_id, - state_id: a.state_id, - estimate: a.estimate, - }, - ] - : []; + // Keep any custom_* keys on the single-case path (spread, don't cherry-pick). + const { project_id, cases, ...single } = a; + const casesPayload = Array.isArray(cases) ? cases : single.name ? [single] : []; if (casesPayload.length === 0) throw new Error("Provide either 'cases' array or 'name' for a single case"); // Bypass SDK constructFromObject to preserve custom fields (custom_*) + casesPayload.forEach(coerceCustomFields); return apis.repositoryCases.createCases(a.project_id, { cases: casesPayload }); } case "testmo_update_case": { // Bypass SDK constructFromObject to preserve custom fields (custom_*) const payload = { ...a }; delete payload.project_id; + coerceCustomFields(payload); return apis.repositoryCases.updateCases(a.project_id, payload); } case "testmo_delete_case": { From 436f15a4e7eba883db208146a5f8dd0cceb5fe9e Mon Sep 17 00:00:00 2001 From: Eddy Mhalli Date: Fri, 31 Jul 2026 11:22:33 +0200 Subject: [PATCH 3/3] Forward expands in get_case and ignore npm/yarn lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testmo_get_case rebuilt its request as a raw HTTP call to keep custom fields, but dropped the caller's `expands` parameter. `issues` — the native Jira tracker links — is absent from the default case payload, so the field was unreadable through this server even though the schema advertised the parameter. Forward it. Also ignore package-lock.json and yarn.lock: this project declares pnpm and tracks pnpm-lock.yaml, so a stray npm lockfile is noise. --- .gitignore | 4 +++- index.js | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 08e30b7..d70eaee 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules .env .env.local *.log -.DS_Store \ No newline at end of file +.DS_Store +package-lock.json +yarn.lock diff --git a/index.js b/index.js index b72f273..b506e4d 100644 --- a/index.js +++ b/index.js @@ -701,7 +701,10 @@ async function handleTool(apis, name, args) { const token = apis.client.authentications.bearerAuth.accessToken; let page = 1; for (;;) { - const url = `${baseUrl}/api/v1/projects/${a.project_id}/cases?page=${page}&per_page=25&sort=repository_cases%3Aid&order=asc`; + // `expands` must be forwarded: `issues` (the native tracker links) is not in the + // default payload, and it is the only way to read a case's Jira links. + const expands = a.expands ? `&expands=${encodeURIComponent(a.expands)}` : ""; + const url = `${baseUrl}/api/v1/projects/${a.project_id}/cases?page=${page}&per_page=25&sort=repository_cases%3Aid&order=asc${expands}`; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, });