Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ node_modules
.env
.env.local
*.log
.DS_Store
.DS_Store
package-lock.json
yarn.lock
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
142 changes: 115 additions & 27 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,25 @@ 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,
},
},
{
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: {
Expand All @@ -258,8 +268,21 @@ 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)" },
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,
},
},
{
Expand Down Expand Up @@ -371,6 +394,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",
Expand Down Expand Up @@ -467,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),
Expand All @@ -480,6 +520,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),
};
}

Expand Down Expand Up @@ -507,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) {
Expand Down Expand Up @@ -620,23 +696,27 @@ 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,
// `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" },
});
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, {
Expand All @@ -647,26 +727,21 @@ 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 }]
: [];
// 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");
const createCase = testmo.CreateRepositoryCase.constructFromObject({ cases: casesPayload });
return apis.repositoryCases.createCases(a.project_id, createCase);
// Bypass SDK constructFromObject to preserve custom fields (custom_*)
casesPayload.forEach(coerceCustomFields);
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;
coerceCustomFields(payload);
return apis.repositoryCases.updateCases(a.project_id, payload);
}
case "testmo_delete_case": {
const deleteCase = testmo.DeleteRepositoryCases.constructFromObject({ ids: a.ids });
Expand Down Expand Up @@ -726,6 +801,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, {
Expand Down Expand Up @@ -819,8 +902,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,
});
}
Expand Down