@smartbear/mcp 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ See individual guides for suggested prompts and supported tools and resources:
35
35
  - [Portal](https://developer.smartbear.com/smartbear-mcp/docs/swagger-portal-integration) - Portal and product management capabilities
36
36
  - [Studio](https://developer.smartbear.com/smartbear-mcp/docs/swagger-studio-integration) - API and Domain management capabilities, including AI-powered API generation from prompts and automatic standardization
37
37
  - [Contract Testing (PactFlow)](https://developer.smartbear.com/pactflow/default/getting-started) - Contract testing capabilities
38
- - [Functional Testing](https://developer.smartbear.com/smartbear-mcp/docs/functional-testing-integration) - API test discovery capabilities
38
+ - [Functional Testing](https://developer.smartbear.com/smartbear-mcp/docs/swagger-functional-testing-integration) - API test discovery capabilities
39
39
  - [QMetry](https://developer.smartbear.com/smartbear-mcp/docs/qmetry-integration) - QMetry Test Management capabilities
40
40
  - [Zephyr](https://developer.smartbear.com/smartbear-mcp/docs/zephyr-integration) - Zephyr Test Management capabilities
41
41
  - [Collaborator](https://developer.smartbear.com/smartbear-mcp/docs/collaborator-integration) - Review and Remote System Configuration management capabilities
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
- import { USER_AGENT } from "../common/info.js";
2
+ import { getUserAgent } from "../common/info.js";
3
3
  import { getRequestHeader } from "../common/request-context.js";
4
4
  import { ToolError } from "../common/tools.js";
5
5
  import { DEFAULT_API_BASE_URL, AUTHORIZATION_HEADER } from "./config/constants.js";
6
+ import { ListEnvironments } from "./tool/environments/list-environments.js";
6
7
  import { ChatWithQaLead } from "./tool/tasks/chat-with-qa-lead.js";
7
8
  import { ExpandApplicationModel } from "./tool/tasks/expand-application-model.js";
8
9
  import { GetTask } from "./tool/tasks/get-task.js";
@@ -53,7 +54,7 @@ class BearQClient {
53
54
  return {
54
55
  Authorization: `Bearer ${token}`,
55
56
  "Content-Type": "application/json",
56
- "User-Agent": USER_AGENT
57
+ "User-Agent": getUserAgent()
57
58
  };
58
59
  }
59
60
  async registerTools(register, _getInput) {
@@ -69,7 +70,8 @@ class BearQClient {
69
70
  new GetTask(this),
70
71
  new GetTaskStatus(this),
71
72
  new StopTask(this),
72
- new WaitForTask(this)
73
+ new WaitForTask(this),
74
+ new ListEnvironments(this)
73
75
  ];
74
76
  for (const tool of tools) {
75
77
  register(tool.specification, tool.handle);
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import { Tool, ToolError } from "../../../common/tools.js";
3
+ const inputSchema = z.object({});
4
+ class ListEnvironments extends Tool {
5
+ specification = {
6
+ title: "List Environments",
7
+ toolset: "Environments",
8
+ summary: "Lists the environments configured in the workspace. Use this to discover valid environment names to pass to the test-running tools, and to identify the workspace default.",
9
+ inputSchema,
10
+ readOnly: true
11
+ };
12
+ handle = async (_args) => {
13
+ const res = await fetch(`${this.client.getBaseUrl()}/environments`, {
14
+ method: "GET",
15
+ headers: this.client.getHeaders()
16
+ });
17
+ if (!res.ok)
18
+ throw new ToolError(
19
+ `GET /environments failed: ${res.status} ${res.statusText}`
20
+ );
21
+ return {
22
+ content: [{ type: "text", text: JSON.stringify(await res.json()) }]
23
+ };
24
+ };
25
+ }
26
+ export {
27
+ ListEnvironments
28
+ };
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { Tool, ToolError } from "../../../common/tools.js";
3
- const inputSchema = z.object({});
3
+ const inputSchema = z.object({
4
+ environment: z.string().min(1).optional().describe(
5
+ "Target environment name to run tests against. Omit to use the workspace default."
6
+ )
7
+ });
4
8
  class RunRegressionTests extends Tool {
5
9
  specification = {
6
10
  title: "Run Regression Tests",
@@ -8,11 +12,14 @@ class RunRegressionTests extends Tool {
8
12
  summary: "Runs the full BearQ regression suite — every regression-ready test case in the workspace. Use for CI/CD or pre-release smoke.",
9
13
  inputSchema
10
14
  };
11
- handle = async (_args) => {
15
+ handle = async (args) => {
16
+ const { environment } = inputSchema.parse(args);
17
+ const body = { agent: "tester", mode: "run" };
18
+ if (environment !== void 0) body.environment = environment;
12
19
  const res = await fetch(`${this.client.getBaseUrl()}/tasks`, {
13
20
  method: "POST",
14
21
  headers: this.client.getHeaders(),
15
- body: JSON.stringify({ agent: "tester", mode: "run" })
22
+ body: JSON.stringify(body)
16
23
  });
17
24
  if (!res.ok)
18
25
  throw new ToolError(
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { Tool, ToolError } from "../../../common/tools.js";
3
3
  const inputSchema = z.object({
4
- testCaseIds: z.array(z.number().int().positive()).min(1).describe("IDs of BearQ regression test cases to run.")
4
+ testCaseIds: z.array(z.number().int().positive()).min(1).describe("IDs of BearQ regression test cases to run."),
5
+ environment: z.string().min(1).optional().describe(
6
+ "Target environment name to run tests against. Omit to use the workspace default."
7
+ )
5
8
  });
6
9
  class RunTestCases extends Tool {
7
10
  specification = {
@@ -11,11 +14,17 @@ class RunTestCases extends Tool {
11
14
  inputSchema
12
15
  };
13
16
  handle = async (args) => {
14
- const { testCaseIds } = inputSchema.parse(args);
17
+ const { testCaseIds, environment } = inputSchema.parse(args);
18
+ const body = {
19
+ agent: "tester",
20
+ mode: "run",
21
+ testCaseIds
22
+ };
23
+ if (environment !== void 0) body.environment = environment;
15
24
  const res = await fetch(`${this.client.getBaseUrl()}/tasks`, {
16
25
  method: "POST",
17
26
  headers: this.client.getHeaders(),
18
- body: JSON.stringify({ agent: "tester", mode: "run", testCaseIds })
27
+ body: JSON.stringify(body)
19
28
  });
20
29
  if (!res.ok)
21
30
  throw new ToolError(
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { Tool, ToolError } from "../../../common/tools.js";
3
3
  const inputSchema = z.object({
4
- functionalAreas: z.array(z.union([z.number().int().positive(), z.string().min(1)])).min(1).describe("Functional areas to target, by ID or name.")
4
+ functionalAreas: z.array(z.union([z.number().int().positive(), z.string().min(1)])).min(1).describe("Functional areas to target, by ID or name."),
5
+ environment: z.string().min(1).optional().describe(
6
+ "Target environment name to run tests against. Omit to use the workspace default."
7
+ )
5
8
  });
6
9
  class RunTestsInFunctionalAreas extends Tool {
7
10
  specification = {
@@ -11,11 +14,17 @@ class RunTestsInFunctionalAreas extends Tool {
11
14
  inputSchema
12
15
  };
13
16
  handle = async (args) => {
14
- const { functionalAreas } = inputSchema.parse(args);
17
+ const { functionalAreas, environment } = inputSchema.parse(args);
18
+ const body = {
19
+ agent: "tester",
20
+ mode: "run",
21
+ functionalAreas
22
+ };
23
+ if (environment !== void 0) body.environment = environment;
15
24
  const res = await fetch(`${this.client.getBaseUrl()}/tasks`, {
16
25
  method: "POST",
17
26
  headers: this.client.getHeaders(),
18
- body: JSON.stringify({ agent: "tester", mode: "run", functionalAreas })
27
+ body: JSON.stringify(body)
19
28
  });
20
29
  if (!res.ok)
21
30
  throw new ToolError(
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { USER_AGENT } from "../common/info.js";
2
+ import { getUserAgent } from "../common/info.js";
3
3
  import { getRequestHeader } from "../common/request-context.js";
4
4
  import { ToolError } from "../common/tools.js";
5
5
  import { CurrentUserAPI } from "./client/api/CurrentUser.js";
@@ -123,7 +123,7 @@ class BugsnagClient {
123
123
  );
124
124
  },
125
125
  headers: {
126
- "User-Agent": USER_AGENT,
126
+ "User-Agent": getUserAgent(),
127
127
  "Content-Type": "application/json",
128
128
  "X-Bugsnag-API": "true",
129
129
  "X-Version": "2"
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { USER_AGENT } from "../common/info.js";
2
+ import { getUserAgent } from "../common/info.js";
3
3
  import { getRequestHeader } from "../common/request-context.js";
4
4
  const ConfigurationSchema = z.object({
5
5
  base_url: z.url().describe("Collaborator server base URL"),
@@ -48,7 +48,10 @@ class CollaboratorClient {
48
48
  ];
49
49
  const response = await fetch(url, {
50
50
  method: "POST",
51
- headers: { "Content-Type": "application/json", "User-Agent": USER_AGENT },
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ "User-Agent": getUserAgent()
54
+ },
52
55
  body: JSON.stringify(body)
53
56
  });
54
57
  if (!response.ok) {
@@ -0,0 +1,20 @@
1
+ import { getRequestContext } from "./request-context.js";
2
+ function toClientIdentity(info, protocolVersion) {
3
+ return {
4
+ name: info?.name,
5
+ version: info?.version,
6
+ protocolVersion
7
+ };
8
+ }
9
+ let processClientIdentity;
10
+ function setProcessClientIdentity(identity) {
11
+ processClientIdentity = identity;
12
+ }
13
+ function getCurrentClientIdentity() {
14
+ return getRequestContext()?.mcpClient ?? processClientIdentity;
15
+ }
16
+ export {
17
+ getCurrentClientIdentity,
18
+ setProcessClientIdentity,
19
+ toClientIdentity
20
+ };
@@ -1,11 +1,31 @@
1
1
  import packageJson from "../package.json.js";
2
+ import { getCurrentClientIdentity } from "./client-identity.js";
2
3
  const MCP_SERVER_NAME = packageJson.config.mcpServerName;
3
4
  const MCP_SERVER_VERSION = packageJson.version;
4
5
  const MCP_TRANSPORT = process.env.MCP_TRANSPORT?.toLowerCase().trim() || "stdio";
5
6
  const USER_AGENT = `${MCP_SERVER_NAME}/${MCP_SERVER_VERSION} ${MCP_TRANSPORT}`;
7
+ function sanitizeUserAgentToken(value) {
8
+ return value.replace(/[()\r\n;]/g, "").trim();
9
+ }
10
+ function clientIdentitySuffix(identity) {
11
+ if (!identity?.name) {
12
+ return "";
13
+ }
14
+ const clientName = sanitizeUserAgentToken(identity.name) || "unknown";
15
+ const version = identity.version ? sanitizeUserAgentToken(identity.version) || "unknown" : "unknown";
16
+ return ` (client: ${clientName}; clientVersion: ${version})`;
17
+ }
18
+ function appendClientIdentity(baseUserAgent) {
19
+ return baseUserAgent + clientIdentitySuffix(getCurrentClientIdentity());
20
+ }
21
+ function getUserAgent() {
22
+ return appendClientIdentity(USER_AGENT);
23
+ }
6
24
  export {
7
25
  MCP_SERVER_NAME,
8
26
  MCP_SERVER_VERSION,
9
27
  MCP_TRANSPORT,
10
- USER_AGENT
28
+ USER_AGENT,
29
+ appendClientIdentity,
30
+ getUserAgent
11
31
  };
@@ -1,8 +1,15 @@
1
+ import { toClientIdentity, setProcessClientIdentity } from "./client-identity.js";
1
2
  function handleInitializeMessage(server, message) {
2
3
  if (typeof message !== "object" || message === null || !("method" in message) || message.method !== "initialize") {
3
4
  return;
4
5
  }
5
6
  const params = message.params;
7
+ const identity = toClientIdentity(
8
+ params?.clientInfo,
9
+ params?.protocolVersion
10
+ );
11
+ server.setMcpClientIdentity(identity);
12
+ setProcessClientIdentity(identity);
6
13
  const clientInfo = params?.clientInfo;
7
14
  if (clientInfo) {
8
15
  server.setClientInfo(clientInfo);
@@ -6,6 +6,12 @@ function withRequestContext(req, fn) {
6
6
  function getRequestContext() {
7
7
  return requestContextStorage.getStore();
8
8
  }
9
+ function setRequestMcpClient(identity) {
10
+ const context = getRequestContext();
11
+ if (context) {
12
+ context.mcpClient = identity;
13
+ }
14
+ }
9
15
  function getRequestHeader(name) {
10
16
  const context = getRequestContext();
11
17
  if (!context?.headers) return void 0;
@@ -16,5 +22,6 @@ export {
16
22
  getRequestContext,
17
23
  getRequestHeader,
18
24
  requestContextStorage,
25
+ setRequestMcpClient,
19
26
  withRequestContext
20
27
  };
@@ -2,6 +2,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
2
2
  import { ZodObject, ZodIntersection } from "zod";
3
3
  import Bugsnag from "./bugsnag.js";
4
4
  import { CacheService } from "./cache.js";
5
+ import { toClientIdentity } from "./client-identity.js";
5
6
  import { MCP_SERVER_VERSION, MCP_SERVER_NAME } from "./info.js";
6
7
  import { executeElicitationOrPolyfill, isElicitationPolyfillResult } from "./pollyfills.js";
7
8
  import { ToolError } from "./tools.js";
@@ -13,6 +14,7 @@ class SmartBearMcpServer extends McpServer {
13
14
  clientInfo;
14
15
  clients = [];
15
16
  enabledToolsets;
17
+ mcpClientIdentity;
16
18
  constructor(enabledToolsets) {
17
19
  super(
18
20
  {
@@ -58,6 +60,33 @@ class SmartBearMcpServer extends McpServer {
58
60
  getClients() {
59
61
  return this.clients;
60
62
  }
63
+ /**
64
+ * Record the MCP client identity reported in the `initialize` handshake.
65
+ * Captured once per session by the transport layer.
66
+ */
67
+ setMcpClientIdentity(identity) {
68
+ this.mcpClientIdentity = identity;
69
+ }
70
+ /**
71
+ * Return the MCP client identity for this session. Prefers the value captured
72
+ * at `initialize`; falls back to the SDK's `getClientVersion()` so callers
73
+ * still get an answer if the explicit capture was skipped.
74
+ */
75
+ getMcpClientIdentity() {
76
+ return this.mcpClientIdentity ?? toClientIdentity(this.server.getClientVersion());
77
+ }
78
+ /**
79
+ * Attach MCP client attribution to a Bugsnag event so errors can be segmented
80
+ * by originating client/marketplace.
81
+ */
82
+ addClientMetadata(event) {
83
+ const identity = this.getMcpClientIdentity();
84
+ event.addMetadata("mcpClient", {
85
+ mcp_client_name: identity.name ?? null,
86
+ mcp_client_version: identity.version ?? null,
87
+ mcp_protocol_version: identity.protocolVersion ?? null
88
+ });
89
+ }
61
90
  async cleanupSession(mcpSessionId) {
62
91
  for (const client of this.clients) {
63
92
  await client.cleanupSession?.(mcpSessionId);
@@ -113,6 +142,7 @@ class SmartBearMcpServer extends McpServer {
113
142
  } else {
114
143
  Bugsnag.notify(e, (event) => {
115
144
  event.addMetadata("app", { tool: toolName });
145
+ this.addClientMetadata(event);
116
146
  event.unhandled = true;
117
147
  });
118
148
  }
@@ -163,6 +193,7 @@ ${result.instructions}`
163
193
  resource: resourceName,
164
194
  url: url2
165
195
  });
196
+ this.addClientMetadata(event);
166
197
  event.unhandled = true;
167
198
  });
168
199
  throw e;
@@ -188,6 +219,7 @@ ${result.instructions}`
188
219
  event.addMetadata("app", {
189
220
  prompt: this.getCapabilityName(client, params.title)
190
221
  });
222
+ this.addClientMetadata(event);
191
223
  event.unhandled = true;
192
224
  });
193
225
  throw e;
@@ -6,7 +6,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
6
6
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { clientRegistry } from "./client-registry.js";
8
8
  import { handleInitializeMessage } from "./initialize.js";
9
- import { withRequestContext } from "./request-context.js";
9
+ import { withRequestContext, setRequestMcpClient } from "./request-context.js";
10
10
  import { SmartBearMcpServer } from "./server.js";
11
11
  import { isDraining, registerShutdownHandler } from "./shutdown.js";
12
12
  import { getEnvVarName } from "./transport-stdio.js";
@@ -269,10 +269,13 @@ async function handleStreamableHttpRequest(req, res, transports) {
269
269
  );
270
270
  return;
271
271
  }
272
- await withRequestContext(
273
- req,
274
- async () => await transport.handleRequest(req, res, parsedBody)
275
- );
272
+ const sessionServer = sessionId ? transports.get(sessionId)?.server : void 0;
273
+ await withRequestContext(req, async () => {
274
+ if (sessionServer) {
275
+ setRequestMcpClient(sessionServer.getMcpClientIdentity());
276
+ }
277
+ return await transport.handleRequest(req, res, parsedBody);
278
+ });
276
279
  } catch (error) {
277
280
  console.error("Error handling StreamableHTTP request:", error);
278
281
  res.writeHead(500, { "Content-Type": "text/plain" });
@@ -285,6 +288,7 @@ async function handleLegacySseRequest(req, res, transports) {
285
288
  return;
286
289
  }
287
290
  const transport = new SSEServerTransport("/message", res);
291
+ transport.onmessage = (message) => handleInitializeMessage(server, message);
288
292
  transports.set(transport.sessionId, { server, transport });
289
293
  res.on("close", () => {
290
294
  transports.delete(transport.sessionId);
@@ -317,14 +321,10 @@ async function handleLegacyMessageRequest(req, res, url, transports) {
317
321
  req.on("end", async () => {
318
322
  try {
319
323
  const parsedBody = JSON.parse(body);
320
- await withRequestContext(
321
- req,
322
- async () => await session.transport.handlePostMessage(
323
- req,
324
- res,
325
- parsedBody
326
- )
327
- );
324
+ await withRequestContext(req, async () => {
325
+ setRequestMcpClient(session.server.getMcpClientIdentity());
326
+ return await session.transport.handlePostMessage(req, res, parsedBody);
327
+ });
328
328
  } catch (error) {
329
329
  console.error("Error handling POST message:", error);
330
330
  res.writeHead(500, { "Content-Type": "text/plain" });
@@ -1,4 +1,4 @@
1
- const version = "0.28.0";
1
+ const version = "0.29.0";
2
2
  const config = { "mcpServerName": "SmartBear MCP Server" };
3
3
  const packageJson = {
4
4
  version,
@@ -1884,7 +1884,7 @@ class PactflowClient {
1884
1884
  `${this.baseUrl}/admin/teams/${encodeURIComponent(teamId)}/users`,
1885
1885
  {
1886
1886
  method: "PUT",
1887
- body: { uuids },
1887
+ body: { users: uuids },
1888
1888
  errorContext: "Set Team Users"
1889
1889
  }
1890
1890
  );
@@ -1,4 +1,4 @@
1
- import { USER_AGENT } from "../../../common/info.js";
1
+ import { getUserAgent } from "../../../common/info.js";
2
2
  import { QMETRY_DEFAULTS } from "../../config/constants.js";
3
3
  import { handleQMetryFetchError, handleQMetryApiError } from "./error-handler.js";
4
4
  async function qmetryRequest({
@@ -16,7 +16,7 @@ async function qmetryRequest({
16
16
  const headers = {
17
17
  apikey: token,
18
18
  project: project || QMETRY_DEFAULTS.PROJECT_KEY,
19
- "User-Agent": USER_AGENT,
19
+ "User-Agent": getUserAgent(),
20
20
  "qmetry-source": "smartbear-mcp"
21
21
  };
22
22
  if (scopeId !== void 0) {
@@ -1,4 +1,4 @@
1
- import { USER_AGENT } from "../../common/info.js";
1
+ import { getUserAgent } from "../../common/info.js";
2
2
  import { QMETRY_DEFAULTS } from "../config/constants.js";
3
3
  import { QMETRY_PATHS } from "../config/rest-endpoints.js";
4
4
  import { DEFAULT_IMPORT_AUTOMATION_PAYLOAD } from "../types/automation.js";
@@ -91,7 +91,7 @@ async function importAutomationResults(token, baseUrl, project, payload) {
91
91
  const headers = {
92
92
  apikey: token,
93
93
  project: finalPayload.projectID || project || QMETRY_DEFAULTS.PROJECT_KEY,
94
- "User-Agent": USER_AGENT,
94
+ "User-Agent": getUserAgent(),
95
95
  "qmetry-source": "smartbear-mcp"
96
96
  // Note: Content-Type will be set automatically by fetch for FormData
97
97
  };
@@ -1,4 +1,4 @@
1
- import { USER_AGENT } from "../../common/info.js";
1
+ import { getUserAgent } from "../../common/info.js";
2
2
  import { CONTENT_TYPES, HTTP_HEADERS } from "../config/constants.js";
3
3
  class AuthService {
4
4
  apiKey;
@@ -13,7 +13,7 @@ class AuthService {
13
13
  return {
14
14
  [HTTP_HEADERS.API_KEY]: this.apiKey,
15
15
  [HTTP_HEADERS.CONTENT_TYPE]: CONTENT_TYPES.JSON,
16
- [HTTP_HEADERS.USER_AGENT]: USER_AGENT,
16
+ [HTTP_HEADERS.USER_AGENT]: getUserAgent(),
17
17
  [HTTP_HEADERS.ACCEPT]: CONTENT_TYPES.JSON
18
18
  };
19
19
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { USER_AGENT } from "../common/info.js";
2
+ import { getUserAgent } from "../common/info.js";
3
3
  import { getRequestHeader } from "../common/request-context.js";
4
4
  import { ToolError } from "../common/tools.js";
5
5
  import { API_KEY_HEADER, REFLECT_API_TOKEN_HEADER, AUTHORIZATION_HEADER } from "./config/constants.js";
@@ -73,7 +73,7 @@ class ReflectClient {
73
73
  return {
74
74
  ...this.getAuthHeader(),
75
75
  "Content-Type": "application/json",
76
- "User-Agent": USER_AGENT
76
+ "User-Agent": getUserAgent()
77
77
  };
78
78
  }
79
79
  getSessionState(sessionId) {
@@ -1,3 +1,4 @@
1
+ import { appendClientIdentity } from "../../common/info.js";
1
2
  import { ToolError } from "../../common/tools.js";
2
3
  import { buildSubdomainCandidate, buildSuffixedSubdomain, buildPortalName, isConflictError, isOrganizationPortalConflict } from "./portal-utils.js";
3
4
  import { findTableOfContentsItem, buildPortalLiveUrl, normalizeSlug } from "./utils.js";
@@ -22,7 +23,7 @@ class SwaggerAPI {
22
23
  this.userAgent = userAgent;
23
24
  }
24
25
  get headers() {
25
- return this.config.getHeaders(this.userAgent);
26
+ return this.config.getHeaders(appendClientIdentity(this.userAgent));
26
27
  }
27
28
  /**
28
29
  * Core response parsing logic shared between different response handlers.
@@ -1,3 +1,4 @@
1
+ import { appendClientIdentity } from "../../common/info.js";
1
2
  import { ToolError } from "../../common/tools.js";
2
3
  const API_HOSTNAME = "api.reflect.run";
3
4
  const FUNCTIONAL_TESTING_API_KEY_HEADER = "X-API-KEY";
@@ -16,11 +17,27 @@ class FunctionalTestingAPI {
16
17
  return {
17
18
  [FUNCTIONAL_TESTING_API_KEY_HEADER]: token,
18
19
  "Content-Type": "application/json",
19
- "User-Agent": this.userAgent
20
+ "User-Agent": appendClientIdentity(this.userAgent)
20
21
  };
21
22
  }
23
+ async ftFetch(input, init) {
24
+ let response;
25
+ try {
26
+ response = await fetch(input, init);
27
+ } catch {
28
+ throw new ToolError(
29
+ "Swagger Functional Testing service is currently unreachable. Retry after a moment."
30
+ );
31
+ }
32
+ if (response.status === 401 || response.status === 403) {
33
+ throw new ToolError(
34
+ "Authentication failed. Verify your API token is valid and has not expired."
35
+ );
36
+ }
37
+ return response;
38
+ }
22
39
  async listTests() {
23
- const response = await fetch(`${this.baseUrl}/tests`, {
40
+ const response = await this.ftFetch(`${this.baseUrl}/tests`, {
24
41
  method: "GET",
25
42
  headers: this.getFtHeaders()
26
43
  });
@@ -33,8 +50,8 @@ class FunctionalTestingAPI {
33
50
  }
34
51
  async runTest(args) {
35
52
  if (!args.testId) throw new ToolError("testId argument is required");
36
- const response = await fetch(
37
- `${this.baseUrl}/tests/${args.testId}/executions`,
53
+ const response = await this.ftFetch(
54
+ `${this.baseUrl}/tests/${encodeURIComponent(args.testId)}/executions`,
38
55
  {
39
56
  method: "POST",
40
57
  headers: this.getFtHeaders()
@@ -51,8 +68,8 @@ class FunctionalTestingAPI {
51
68
  if (!args.executionId) {
52
69
  throw new ToolError("executionId argument is required");
53
70
  }
54
- const response = await fetch(
55
- `${this.baseUrl}/executions/${args.executionId}`,
71
+ const response = await this.ftFetch(
72
+ `${this.baseUrl}/executions/${encodeURIComponent(args.executionId)}`,
56
73
  {
57
74
  method: "GET",
58
75
  headers: this.getFtHeaders()
@@ -63,16 +80,87 @@ class FunctionalTestingAPI {
63
80
  `Failed to get test status: ${response.status} ${response.statusText}`
64
81
  );
65
82
  }
66
- return response.json();
83
+ const data = await response.json();
84
+ if (Array.isArray(data.tests)) {
85
+ for (const test of data.tests) {
86
+ const run = test.run;
87
+ if (run) {
88
+ delete run.videoUrl;
89
+ }
90
+ }
91
+ }
92
+ return data;
93
+ }
94
+ async listSuiteExecutions(args) {
95
+ if (!args.suiteId) throw new ToolError("suiteId argument is required");
96
+ const response = await this.ftFetch(
97
+ `${this.baseUrl}/suites/${encodeURIComponent(args.suiteId)}/executions`,
98
+ {
99
+ method: "GET",
100
+ headers: this.getFtHeaders()
101
+ }
102
+ );
103
+ if (!response.ok) {
104
+ throw new ToolError(suiteExecutionsErrorMessage(response));
105
+ }
106
+ const data = await response.json();
107
+ return {
108
+ ...data,
109
+ executions: {
110
+ data: data.executions.data.map(
111
+ ({ executionId, status, isFinished }) => ({
112
+ executionId,
113
+ status,
114
+ isFinished
115
+ })
116
+ )
117
+ }
118
+ };
67
119
  }
68
120
  async listSuites() {
69
- const headers = this.getFtHeaders();
121
+ const response = await this.ftFetch(`${this.baseUrl}/suites`, {
122
+ method: "GET",
123
+ headers: this.getFtHeaders()
124
+ });
125
+ if (!response.ok) {
126
+ throw new ToolError(
127
+ `Failed to list Functional Testing suites: ${response.status} ${response.statusText}`
128
+ );
129
+ }
130
+ return response.json();
131
+ }
132
+ async cancelSuiteExecution(args) {
133
+ if (!args.suiteId) throw new ToolError("suiteId argument is required");
134
+ if (!args.executionId) {
135
+ throw new ToolError("executionId argument is required");
136
+ }
137
+ const response = await this.ftFetch(
138
+ `${this.baseUrl}/suites/${encodeURIComponent(args.suiteId)}/executions/${encodeURIComponent(args.executionId)}/cancel`,
139
+ {
140
+ method: "PATCH",
141
+ headers: this.getFtHeaders()
142
+ }
143
+ );
144
+ if (!response.ok) {
145
+ throw new ToolError(cancelSuiteExecutionErrorMessage(response));
146
+ }
147
+ return response.json();
148
+ }
149
+ async runSuite(args) {
150
+ if (!args.suiteId) throw new ToolError("suiteId argument is required");
151
+ const body = args.tunnelAgentName ? JSON.stringify({
152
+ overrides: { agent: { name: args.tunnelAgentName } }
153
+ }) : void 0;
70
154
  let response;
71
155
  try {
72
- response = await fetch(`${this.baseUrl}/suites`, {
73
- method: "GET",
74
- headers
75
- });
156
+ response = await fetch(
157
+ `${this.baseUrl}/suites/${args.suiteId}/executions`,
158
+ {
159
+ method: "POST",
160
+ headers: this.getFtHeaders(),
161
+ body
162
+ }
163
+ );
76
164
  } catch {
77
165
  throw new ToolError(
78
166
  "Swagger Functional Testing service is currently unreachable. Retry after a moment."
@@ -85,10 +173,79 @@ class FunctionalTestingAPI {
85
173
  }
86
174
  if (!response.ok) {
87
175
  throw new ToolError(
88
- `Failed to list Functional Testing suites: ${response.status} ${response.statusText}`
176
+ `Failed to run suite: ${response.status} ${response.statusText}`
89
177
  );
90
178
  }
91
- return response.json();
179
+ return this.withoutField("url", response);
180
+ }
181
+ async getSuiteExecution(args) {
182
+ if (!args.suiteId) throw new ToolError("suiteId argument is required");
183
+ if (!args.executionId) {
184
+ throw new ToolError("executionId argument is required");
185
+ }
186
+ let response;
187
+ try {
188
+ response = await fetch(
189
+ `${this.baseUrl}/suites/${args.suiteId}/executions/${args.executionId}`,
190
+ {
191
+ method: "GET",
192
+ headers: this.getFtHeaders()
193
+ }
194
+ );
195
+ } catch {
196
+ throw new ToolError(
197
+ "Swagger Functional Testing service is currently unreachable. Retry after a moment."
198
+ );
199
+ }
200
+ if (response.status === 401 || response.status === 403) {
201
+ throw new ToolError(
202
+ "Authentication failed. Verify your API token is valid and has not expired."
203
+ );
204
+ }
205
+ if (!response.ok) {
206
+ throw new ToolError(
207
+ `Failed to get suite execution status: ${response.status} ${response.statusText}`
208
+ );
209
+ }
210
+ const data = await this.withoutField("url", response);
211
+ const testsData = data.tests?.data;
212
+ if (Array.isArray(testsData)) {
213
+ for (const test of testsData) {
214
+ if (Array.isArray(test.runs)) {
215
+ for (const run of test.runs) {
216
+ delete run.videoUrl;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ return data;
222
+ }
223
+ async withoutField(field, response) {
224
+ const data = await response.json();
225
+ delete data[field];
226
+ return data;
227
+ }
228
+ }
229
+ function suiteExecutionsErrorMessage(response) {
230
+ switch (response.status) {
231
+ // Defensive: the Reflect API currently returns 200 with an empty
232
+ // `executions.data` list for an unknown suiteId rather than a 404, so this
233
+ // branch is not expected to fire today. Kept in case the API starts
234
+ // returning 404 for missing suites.
235
+ case 404:
236
+ return "Test suite not found. Verify the suiteId is correct and belongs to your workspace.";
237
+ default:
238
+ return `Failed to list suite executions: ${response.status} ${response.statusText}`;
239
+ }
240
+ }
241
+ function cancelSuiteExecutionErrorMessage(response) {
242
+ switch (response.status) {
243
+ case 404:
244
+ return "Suite execution not found. Verify the suiteId and executionId are correct and belong to your workspace.";
245
+ case 409:
246
+ return "Suite execution cannot be cancelled because it has already finished.";
247
+ default:
248
+ return `Failed to cancel suite execution: ${response.status} ${response.statusText}`;
92
249
  }
93
250
  }
94
251
  export {
@@ -1,10 +1,12 @@
1
- import { RunFunctionalTestingTestParamsSchema, GetFunctionalTestingExecutionTestSchema } from "./functional-testing-types.js";
1
+ import { RunFunctionalTestingTestParamsSchema, GetFunctionalTestingExecutionTestSchema, ListFunctionalTestingSuiteExecutionsSchema, CancelFunctionalTestingSuiteExecutionSchema, RunFunctionalTestingSuiteParamsSchema, GetFunctionalTestingSuiteExecutionSchema } from "./functional-testing-types.js";
2
2
  const FUNCTIONAL_TESTING_TOOLS = [
3
3
  {
4
4
  title: "List Tests",
5
5
  toolset: "Functional Testing",
6
6
  summary: "Lists all API tests available in your Swagger Functional Testing account. Use this tool when you need to discover available tests before running them or checking their status. Do not use this tool to retrieve test execution results or history.",
7
- handler: "listFunctionalTestingTests"
7
+ handler: "listFunctionalTestingTests",
8
+ idempotent: true,
9
+ readOnly: true
8
10
  },
9
11
  {
10
12
  title: "Run Test",
@@ -21,6 +23,25 @@ const FUNCTIONAL_TESTING_TOOLS = [
21
23
  summary: "Get the status of a Swagger Functional Testing test execution. It returns information about the execution such as its status (running, passed or failed), run time, as well as the break down of the status of each test step.",
22
24
  inputSchema: GetFunctionalTestingExecutionTestSchema,
23
25
  handler: "getFunctionalTestingExecution",
26
+ idempotent: true,
27
+ readOnly: true
28
+ },
29
+ {
30
+ title: "List Suite Executions",
31
+ toolset: "Functional Testing",
32
+ summary: "Lists all executions for a given test suite in your Swagger Functional Testing workspace. Use this tool when you need to review execution history and timings for a specific suite. Do not use this tool to retrieve the status of a single execution or individual test results.",
33
+ inputSchema: ListFunctionalTestingSuiteExecutionsSchema,
34
+ handler: "listFunctionalTestingSuiteExecutions",
35
+ readOnly: true,
36
+ idempotent: true
37
+ },
38
+ {
39
+ title: "Cancel Suite Execution",
40
+ toolset: "Functional Testing",
41
+ summary: "Cancels an ongoing test suite execution in your Swagger Functional Testing workspace. Use this tool when you need to stop a long-running or accidentally triggered suite run. Do not use this tool to cancel individual test runs.",
42
+ inputSchema: CancelFunctionalTestingSuiteExecutionSchema,
43
+ handler: "cancelFunctionalTestingSuiteExecution",
44
+ readOnly: false,
24
45
  idempotent: false
25
46
  },
26
47
  {
@@ -30,6 +51,24 @@ const FUNCTIONAL_TESTING_TOOLS = [
30
51
  handler: "listFunctionalTestingSuites",
31
52
  idempotent: true,
32
53
  readOnly: true
54
+ },
55
+ {
56
+ title: "Run Suite",
57
+ toolset: "Functional Testing",
58
+ summary: "Runs a specific test suite in your Swagger Functional Testing workspace. The execution is asynchronous — it returns an executionId, not results directly. Use `swagger_get_suite_status` with your suiteId and executionId to track progress and retrieve the final per-test results. Optionally accepts a `tunnelAgentName` argument to override the suite's saved tunnel for this run. Do not use this tool to run a single test — use `swagger_run_test` instead.",
59
+ inputSchema: RunFunctionalTestingSuiteParamsSchema,
60
+ handler: "runFunctionalTestingSuite",
61
+ idempotent: false,
62
+ readOnly: false
63
+ },
64
+ {
65
+ title: "Get Suite Status",
66
+ toolset: "Functional Testing",
67
+ summary: "Get the status of a Swagger Functional Testing suite execution. Returns the overall status (pending, canceled, passed or failed), whether the run is finished, and a per-test breakdown with pass/fail. Use this to poll for the outcome of a suite run triggered by `swagger_run_suite`. Requires both `suiteId` and the `executionId` arguments returned by `swagger_run_suite`.",
68
+ inputSchema: GetFunctionalTestingSuiteExecutionSchema,
69
+ handler: "getFunctionalTestingSuiteExecution",
70
+ idempotent: true,
71
+ readOnly: true
33
72
  }
34
73
  ];
35
74
  export {
@@ -5,7 +5,28 @@ const RunFunctionalTestingTestParamsSchema = z.object({
5
5
  const GetFunctionalTestingExecutionTestSchema = z.object({
6
6
  executionId: z.string().describe("ID of the Functional Testing execution").trim().min(1)
7
7
  });
8
+ const ListFunctionalTestingSuiteExecutionsSchema = z.object({
9
+ suiteId: z.string().describe("ID of the Functional Testing suite to list executions for").trim().min(1)
10
+ });
11
+ const CancelFunctionalTestingSuiteExecutionSchema = z.object({
12
+ suiteId: z.string().describe("ID of the Functional Testing suite the execution belongs to").trim().min(1),
13
+ executionId: z.string().describe("ID of the Functional Testing suite execution to cancel").trim().min(1)
14
+ });
15
+ const RunFunctionalTestingSuiteParamsSchema = z.object({
16
+ suiteId: z.string().describe("ID of the Functional Testing suite to run").trim().min(1),
17
+ tunnelAgentName: z.string().describe(
18
+ "Optional tunnel agent name to override the suite's saved tunnel for this run. When omitted, the suite's saved tunnel overrides are used, falling back to each test's saved tunnel."
19
+ ).trim().min(1).optional()
20
+ });
21
+ const GetFunctionalTestingSuiteExecutionSchema = z.object({
22
+ suiteId: z.string().describe("ID of the Functional Testing suite").trim().min(1),
23
+ executionId: z.string().describe("ID of the Functional Testing suite execution").trim().min(1)
24
+ });
8
25
  export {
26
+ CancelFunctionalTestingSuiteExecutionSchema,
9
27
  GetFunctionalTestingExecutionTestSchema,
28
+ GetFunctionalTestingSuiteExecutionSchema,
29
+ ListFunctionalTestingSuiteExecutionsSchema,
30
+ RunFunctionalTestingSuiteParamsSchema,
10
31
  RunFunctionalTestingTestParamsSchema
11
32
  };
@@ -181,6 +181,22 @@ class SwaggerClient {
181
181
  async getFunctionalTestingExecution(args) {
182
182
  return this.withFunctionalTesting((ftApi) => ftApi.getTestExecution(args));
183
183
  }
184
+ async listFunctionalTestingSuiteExecutions(args) {
185
+ return this.withFunctionalTesting(
186
+ (ftApi) => ftApi.listSuiteExecutions(args)
187
+ );
188
+ }
189
+ async cancelFunctionalTestingSuiteExecution(args) {
190
+ return this.withFunctionalTesting(
191
+ (ftApi) => ftApi.cancelSuiteExecution(args)
192
+ );
193
+ }
194
+ async runFunctionalTestingSuite(args) {
195
+ return this.withFunctionalTesting((ftApi) => ftApi.runSuite(args));
196
+ }
197
+ async getFunctionalTestingSuiteExecution(args) {
198
+ return this.withFunctionalTesting((ftApi) => ftApi.getSuiteExecution(args));
199
+ }
184
200
  /**
185
201
  * Perform an operation with the Functional Testing API.
186
202
  * Throws a ToolError if Functional Testing is not configured
@@ -1,4 +1,4 @@
1
- import { USER_AGENT } from "../../common/info.js";
1
+ import { getUserAgent } from "../../common/info.js";
2
2
  class AuthService {
3
3
  bearerToken;
4
4
  constructor(accessToken) {
@@ -8,7 +8,7 @@ class AuthService {
8
8
  return {
9
9
  Authorization: `Bearer ${this.bearerToken}`,
10
10
  "Content-Type": "application/json",
11
- "User-Agent": USER_AGENT,
11
+ "User-Agent": getUserAgent(),
12
12
  "zscale-source": "smartbear-mcp"
13
13
  };
14
14
  }
@@ -144,7 +144,7 @@ const createTestCaseBodyProjectKeyRegExp = /([A-Z][A-Z_0-9]+)/;
144
144
  const createTestCaseBodyNameMax = 255;
145
145
  const createTestCaseBodyNameRegExp = /^(?!\\s*$).+/;
146
146
  const createTestCaseBodyEstimatedTimeMin = 0;
147
- const createTestCaseBodyComponentIdMin = 0;
147
+ const createTestCaseBodyComponentIdMin = 1;
148
148
  const createTestCaseBodyPriorityNameMax = 255;
149
149
  const createTestCaseBodyStatusNameMax = 255;
150
150
  const createTestCaseBodyOwnerIdRegExp = /^[-:a-zA-Z0-9]{1,128}$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartbear/mcp",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "MCP server for interacting SmartBear Products",
5
5
  "keywords": [
6
6
  "smartbear",
@@ -45,6 +45,7 @@
45
45
  "test:coverage": "vitest --coverage",
46
46
  "test:coverage:ci": "vitest --coverage --reporter=verbose",
47
47
  "test:run": "vitest run",
48
+ "test:pact": "vitest run --config vitest.pact.config.ts",
48
49
  "coverage:check": "vitest --coverage --reporter=verbose",
49
50
  "bump": "node scripts/bump.js"
50
51
  },
@@ -60,6 +61,7 @@
60
61
  },
61
62
  "devDependencies": {
62
63
  "@biomejs/biome": "^2.4.8",
64
+ "@pact-foundation/pact": "^17.0.1",
63
65
  "@types/js-yaml": "^4.0.9",
64
66
  "@types/node": "^22.19.15",
65
67
  "@types/ws": "^8.18.1",