@smartbear/mcp 0.19.2 → 0.20.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 +15 -2
- package/assets/icon.png +0 -0
- package/dist/bugsnag/client.js +19 -12
- package/dist/collaborator/client.js +10 -10
- package/dist/common/client-registry.js +2 -2
- package/dist/common/server.js +74 -111
- package/dist/common/shutdown.js +165 -0
- package/dist/common/transport-http.js +94 -12
- package/dist/common/transport-stdio.js +16 -2
- package/dist/common/zod-utils.js +62 -7
- package/dist/index.js +2 -0
- package/dist/package.json.js +1 -1
- package/dist/pactflow/client/prompts.js +19 -18
- package/dist/pactflow/client/tools.js +8 -13
- package/dist/pactflow/client.js +26 -12
- package/dist/qmetry/client/tools/testsuite-tools.js +2 -2
- package/dist/qmetry/client.js +1 -1
- package/dist/reflect/client.js +3 -3
- package/dist/reflect/prompt/sap-test.js +5 -5
- package/dist/reflect/tool/recording/add-prompt-step.js +6 -14
- package/dist/reflect/tool/recording/add-segment.js +4 -14
- package/dist/reflect/tool/recording/connect-to-session.js +3 -8
- package/dist/reflect/tool/recording/delete-previous-step.js +3 -8
- package/dist/reflect/tool/recording/get-screenshot.js +4 -14
- package/dist/reflect/tool/suites/cancel-suite-execution.js +4 -14
- package/dist/reflect/tool/suites/execute-suite.js +3 -8
- package/dist/reflect/tool/suites/get-suite-execution-status.js +4 -14
- package/dist/reflect/tool/suites/list-suite-executions.js +3 -8
- package/dist/reflect/tool/suites/list-suites.js +2 -1
- package/dist/reflect/tool/tests/get-test-status.js +3 -8
- package/dist/reflect/tool/tests/list-segments.js +5 -20
- package/dist/reflect/tool/tests/list-tests.js +2 -1
- package/dist/reflect/tool/tests/run-test.js +3 -8
- package/dist/swagger/client/api.js +11 -2
- package/dist/swagger/client/portal-types.js +0 -3
- package/dist/swagger/client/tools.js +0 -1
- package/dist/swagger/client.js +1 -1
- package/dist/zephyr/client.js +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -38,6 +38,19 @@ See individual guides for suggested prompts and supported tools and resources:
|
|
|
38
38
|
- [Zephyr](https://developer.smartbear.com/smartbear-mcp/docs/zephyr-integration) - Zephyr Test Management capabilities
|
|
39
39
|
- [Collaborator](https://developer.smartbear.com/smartbear-mcp/docs/collaborator-integration) - Review and Remote System Configuration management capabilities
|
|
40
40
|
|
|
41
|
+
## Remote MCP Servers
|
|
42
|
+
|
|
43
|
+
For BugSnag, Swagger, and Zephyr, SmartBear hosts Remote MCP Servers that you can connect to directly from your MCP client via a URL — no installation, Node.js, or API tokens required. Authentication is handled through an OAuth browser flow.
|
|
44
|
+
|
|
45
|
+
| Product | Server URL |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **Swagger** | `https://swagger.mcp.smartbear.com/mcp` |
|
|
48
|
+
| **BugSnag** | `https://bugsnag.mcp.smartbear.com/mcp` |
|
|
49
|
+
| **Zephyr** | `https://zephyr.mcp.smartbear.com/mcp` |
|
|
50
|
+
|
|
51
|
+
See the [Remote MCP Servers guide](https://developer.smartbear.com/smartbear-mcp/docs/remote-mcp-servers) for per-client setup instructions. You can connect to multiple remote servers at the same time.
|
|
52
|
+
|
|
53
|
+
> **Need Reflect, QMetry, PactFlow, or Collaborator?** These products are only available via the local npm package below, which bundles all products into a single MCP server.
|
|
41
54
|
|
|
42
55
|
## Prerequisites
|
|
43
56
|
|
|
@@ -45,9 +58,9 @@ See individual guides for suggested prompts and supported tools and resources:
|
|
|
45
58
|
- Access to SmartBear products (BugSnag, Reflect, Swagger, QMetry, or Zephyr)
|
|
46
59
|
- Valid API tokens for the products you want to integrate
|
|
47
60
|
|
|
48
|
-
## Installation
|
|
61
|
+
## Local MCP Server Installation (npm)
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
For all products — or if you prefer running the server locally — the MCP server is distributed as an npm package [`@smartbear/mcp`](https://www.npmjs.com/package/@smartbear/mcp), making it easy to integrate into your development workflow.
|
|
51
64
|
|
|
52
65
|
The server is started with the API key or auth token that you use with your SmartBear product(s). They are optional and can be removed from your configuration if you aren't using the product. For BugSnag, if you provide a project API key it will narrow down all searches to a single project in your BugSnag dashboard. Leave this field blank if you wish to interact across multiple projects at a time.
|
|
53
66
|
|
package/assets/icon.png
ADDED
|
Binary file
|
package/dist/bugsnag/client.js
CHANGED
|
@@ -70,7 +70,7 @@ class BugsnagClient {
|
|
|
70
70
|
return this._appEndpoint;
|
|
71
71
|
}
|
|
72
72
|
name = "BugSnag";
|
|
73
|
-
|
|
73
|
+
capabilityPrefix = "bugsnag";
|
|
74
74
|
configPrefix = "Bugsnag";
|
|
75
75
|
config = ConfigurationSchema;
|
|
76
76
|
async configure(server, config) {
|
|
@@ -329,17 +329,24 @@ class BugsnagClient {
|
|
|
329
329
|
register(tool.specification, tool.handle);
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
|
-
registerResources(register) {
|
|
333
|
-
register(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
332
|
+
async registerResources(register) {
|
|
333
|
+
register(
|
|
334
|
+
{
|
|
335
|
+
title: "Event",
|
|
336
|
+
path: "{id}",
|
|
337
|
+
description: "Retrieve a specific event by its ID."
|
|
338
|
+
},
|
|
339
|
+
async (uri, variables, _extra) => {
|
|
340
|
+
return {
|
|
341
|
+
contents: [
|
|
342
|
+
{
|
|
343
|
+
uri: uri.href,
|
|
344
|
+
text: JSON.stringify(await this.getEvent(variables.id))
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
);
|
|
343
350
|
}
|
|
344
351
|
}
|
|
345
352
|
export {
|
|
@@ -7,7 +7,7 @@ const ConfigurationSchema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
class CollaboratorClient {
|
|
9
9
|
name = "Collaborator";
|
|
10
|
-
|
|
10
|
+
capabilityPrefix = "collaborator";
|
|
11
11
|
configPrefix = "Collaborator";
|
|
12
12
|
config = ConfigurationSchema;
|
|
13
13
|
baseUrl;
|
|
@@ -63,7 +63,7 @@ class CollaboratorClient {
|
|
|
63
63
|
async registerTools(register, _getInput) {
|
|
64
64
|
register(
|
|
65
65
|
{
|
|
66
|
-
title: "Find
|
|
66
|
+
title: "Find Review By ID",
|
|
67
67
|
summary: "Finds a review in Collaborator by its review ID.",
|
|
68
68
|
inputSchema: z.object({
|
|
69
69
|
reviewId: z.string().describe("The Collaborator review ID to find.")
|
|
@@ -85,7 +85,7 @@ class CollaboratorClient {
|
|
|
85
85
|
);
|
|
86
86
|
register(
|
|
87
87
|
{
|
|
88
|
-
title: "Create
|
|
88
|
+
title: "Create Review",
|
|
89
89
|
summary: "Creates a new review in Collaborator. All parameters are optional.",
|
|
90
90
|
inputSchema: z.object({
|
|
91
91
|
creator: z.string().optional().describe(
|
|
@@ -122,7 +122,7 @@ class CollaboratorClient {
|
|
|
122
122
|
);
|
|
123
123
|
register(
|
|
124
124
|
{
|
|
125
|
-
title: "Reject
|
|
125
|
+
title: "Reject Review",
|
|
126
126
|
summary: "Rejects a review in Collaborator by its review ID and reason.",
|
|
127
127
|
inputSchema: z.object({
|
|
128
128
|
reviewId: z.union([z.string(), z.number()]).describe("The Collaborator review ID to reject."),
|
|
@@ -171,7 +171,7 @@ class CollaboratorClient {
|
|
|
171
171
|
);
|
|
172
172
|
register(
|
|
173
173
|
{
|
|
174
|
-
title: "Get
|
|
174
|
+
title: "Get Reviews",
|
|
175
175
|
summary: "Retrieves reviews from Collaborator using ReviewService.getReviews. All parameters are optional and only provided ones are sent.",
|
|
176
176
|
inputSchema: z.object({
|
|
177
177
|
login: z.string().optional().describe("Collaborator username to filter reviews."),
|
|
@@ -207,7 +207,7 @@ class CollaboratorClient {
|
|
|
207
207
|
);
|
|
208
208
|
register(
|
|
209
209
|
{
|
|
210
|
-
title: "Create
|
|
210
|
+
title: "Create Remote System Configuration",
|
|
211
211
|
summary: "Creates a remote system configuration in Collaborator (e.g., Bitbucket, GitHub, etc).",
|
|
212
212
|
inputSchema: z.object({
|
|
213
213
|
token: z.string().describe("Remote system token, e.g., BITBUCKET, GITHUB, etc."),
|
|
@@ -238,7 +238,7 @@ class CollaboratorClient {
|
|
|
238
238
|
);
|
|
239
239
|
register(
|
|
240
240
|
{
|
|
241
|
-
title: "Edit
|
|
241
|
+
title: "Edit Remote System Configuration",
|
|
242
242
|
summary: "Edits parameters of an existing remote system configuration in Collaborator. Only title and config are editable after creation.",
|
|
243
243
|
inputSchema: z.object({
|
|
244
244
|
id: z.string().describe("ID of the remote system Configuration to edit."),
|
|
@@ -271,7 +271,7 @@ class CollaboratorClient {
|
|
|
271
271
|
);
|
|
272
272
|
register(
|
|
273
273
|
{
|
|
274
|
-
title: "Delete
|
|
274
|
+
title: "Delete Remote System Configuration",
|
|
275
275
|
summary: "Deletes a remote system configuration in Collaborator by its ID.",
|
|
276
276
|
inputSchema: z.object({
|
|
277
277
|
id: z.union([z.string(), z.number()]).describe("ID of the remote system Configuration to delete.")
|
|
@@ -295,7 +295,7 @@ class CollaboratorClient {
|
|
|
295
295
|
);
|
|
296
296
|
register(
|
|
297
297
|
{
|
|
298
|
-
title: "Update
|
|
298
|
+
title: "Update Remote System Configuration Webhook",
|
|
299
299
|
summary: "Updates the webhook for a remote system configuration in Collaborator by its ID.",
|
|
300
300
|
inputSchema: z.object({
|
|
301
301
|
id: z.union([z.string(), z.number()]).describe(
|
|
@@ -321,7 +321,7 @@ class CollaboratorClient {
|
|
|
321
321
|
);
|
|
322
322
|
register(
|
|
323
323
|
{
|
|
324
|
-
title: "Test
|
|
324
|
+
title: "Test Remote System Configuration Connection",
|
|
325
325
|
summary: "Tests the connection for a remote system configuration in Collaborator by its ID.",
|
|
326
326
|
inputSchema: z.object({
|
|
327
327
|
id: z.union([z.string(), z.number()]).describe(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ZodURL } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { fullyUnwrapZodType, isOptionalType } from "./zod-utils.js";
|
|
3
3
|
class ClientRegistry {
|
|
4
4
|
entries = [];
|
|
5
5
|
enabledClients = null;
|
|
@@ -35,7 +35,7 @@ class ClientRegistry {
|
|
|
35
35
|
* @param value The actual config value to validate
|
|
36
36
|
*/
|
|
37
37
|
validateAllowedEndpoint(zodType, value) {
|
|
38
|
-
if (
|
|
38
|
+
if (fullyUnwrapZodType(zodType) instanceof ZodURL) {
|
|
39
39
|
const allowedEndpoints = process.env.MCP_ALLOWED_ENDPOINTS?.split(",");
|
|
40
40
|
if (allowedEndpoints) {
|
|
41
41
|
for (const endpoint of allowedEndpoints) {
|
package/dist/common/server.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { ZodObject, ZodIntersection
|
|
2
|
+
import { ZodObject, ZodIntersection } from "zod";
|
|
3
3
|
import Bugsnag from "./bugsnag.js";
|
|
4
4
|
import { CacheService } from "./cache.js";
|
|
5
5
|
import { MCP_SERVER_VERSION, MCP_SERVER_NAME } from "./info.js";
|
|
6
6
|
import { executeElicitationOrPolyfill, isElicitationPolyfillResult } from "./pollyfills.js";
|
|
7
7
|
import { ToolError } from "./tools.js";
|
|
8
|
-
import {
|
|
8
|
+
import { getTypeDescription, getDefaultValue, getReadableTypeName, isOptionalType } from "./zod-utils.js";
|
|
9
9
|
class SmartBearMcpServer extends McpServer {
|
|
10
10
|
cache;
|
|
11
11
|
samplingSupported = false;
|
|
@@ -18,24 +18,15 @@ class SmartBearMcpServer extends McpServer {
|
|
|
18
18
|
version: MCP_SERVER_VERSION
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
instructions: `When creating or editing a Reflect test using a connected recording session, follow these guidelines:
|
|
22
|
-
|
|
23
|
-
1. After connecting to a session, get the list of segments for the session's platform type so you know what actions could be added via segments vs needing to create new steps. Do not list tests, only list segments.
|
|
24
|
-
2. Before performing an action, take a screenshot to understand the current state of the application.
|
|
25
|
-
3. Each add_prompt_step request should perform a single action or assertion. Do not combine multiple actions or assertions into a single step.
|
|
26
|
-
4. Only perform one action at a time unless you're sure the action won't move the application to a different screen. For example, you can send multiple add_prompt_step requests to fill out individual form fields if those fields are visible on the current screen.
|
|
27
|
-
5. Check the list of existing Segments to see if a Segment exists that achieves a similar goal to what you're trying to do next. If so, add the segment instead of creating new steps.
|
|
28
|
-
6. If a step fails, use delete_previous_step to remove it and try a different approach.
|
|
29
|
-
7. After completing a task, if the task required multiple prompt steps, add a final prompt step that validates the current state of the page based on what you see on the screen. In your validation, do not reference information that can change from run to run.`,
|
|
30
21
|
capabilities: {
|
|
31
|
-
resources: { listChanged: true },
|
|
32
|
-
// Server supports dynamic resource lists
|
|
33
22
|
tools: { listChanged: true },
|
|
34
23
|
// Server supports dynamic tool lists
|
|
35
|
-
|
|
36
|
-
// Server supports
|
|
37
|
-
prompts: {}
|
|
24
|
+
resources: { listChanged: true },
|
|
25
|
+
// Server supports dynamic resource lists
|
|
26
|
+
prompts: { listChanged: true },
|
|
38
27
|
// Server supports sending prompts to Host
|
|
28
|
+
logging: {}
|
|
29
|
+
// Server supports logging messages
|
|
39
30
|
}
|
|
40
31
|
}
|
|
41
32
|
);
|
|
@@ -68,15 +59,20 @@ class SmartBearMcpServer extends McpServer {
|
|
|
68
59
|
this.clients.push(client);
|
|
69
60
|
await client.registerTools(
|
|
70
61
|
(params, cb) => {
|
|
71
|
-
const toolName =
|
|
72
|
-
const toolTitle =
|
|
62
|
+
const toolName = this.getCapabilityName(client, params.title);
|
|
63
|
+
const toolTitle = this.getCapabilityTitle(client, params.title);
|
|
64
|
+
if (toolName.length > 64) {
|
|
65
|
+
throw new ToolError(
|
|
66
|
+
`The tool name "${toolName}" is too long. Tool names must be 64 characters or fewer for client compatibility. https://github.com/anthropics/claude-code/issues/34960`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
73
69
|
return super.registerTool(
|
|
74
70
|
toolName,
|
|
75
71
|
{
|
|
76
72
|
title: toolTitle,
|
|
77
73
|
description: this.getDescription(params),
|
|
78
|
-
inputSchema: this.
|
|
79
|
-
outputSchema: this.
|
|
74
|
+
inputSchema: params.inputSchema ? this.schemaToRawShape(params.inputSchema) : {},
|
|
75
|
+
outputSchema: this.schemaToRawShape(params.outputSchema),
|
|
80
76
|
annotations: this.getAnnotations(toolTitle, params)
|
|
81
77
|
},
|
|
82
78
|
async (args, extra) => {
|
|
@@ -104,13 +100,10 @@ class SmartBearMcpServer extends McpServer {
|
|
|
104
100
|
]
|
|
105
101
|
};
|
|
106
102
|
} else {
|
|
107
|
-
Bugsnag.notify(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
event.unhandled = true;
|
|
112
|
-
}
|
|
113
|
-
);
|
|
103
|
+
Bugsnag.notify(e, (event) => {
|
|
104
|
+
event.addMetadata("app", { tool: toolName });
|
|
105
|
+
event.unhandled = true;
|
|
106
|
+
});
|
|
114
107
|
}
|
|
115
108
|
throw e;
|
|
116
109
|
}
|
|
@@ -137,25 +130,30 @@ ${result.instructions}`
|
|
|
137
130
|
}
|
|
138
131
|
);
|
|
139
132
|
if (client.registerResources) {
|
|
140
|
-
client.registerResources((
|
|
141
|
-
const
|
|
133
|
+
await client.registerResources((params, cb) => {
|
|
134
|
+
const resourceName = this.getCapabilityName(client, params.title);
|
|
135
|
+
const slug = params.title.replace(/\s+/g, "_").toLowerCase();
|
|
136
|
+
const url = `${client.capabilityPrefix}://${slug}/${params.path}`;
|
|
142
137
|
return super.registerResource(
|
|
143
|
-
|
|
138
|
+
resourceName,
|
|
144
139
|
new ResourceTemplate(url, {
|
|
145
140
|
list: void 0
|
|
146
141
|
}),
|
|
147
|
-
{
|
|
142
|
+
{
|
|
143
|
+
title: this.getCapabilityTitle(client, params.title),
|
|
144
|
+
description: params.description
|
|
145
|
+
},
|
|
148
146
|
async (url2, variables, extra) => {
|
|
149
147
|
try {
|
|
150
148
|
return await cb(url2, variables, extra);
|
|
151
149
|
} catch (e) {
|
|
152
|
-
Bugsnag.notify(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
150
|
+
Bugsnag.notify(e, (event) => {
|
|
151
|
+
event.addMetadata("app", {
|
|
152
|
+
resource: resourceName,
|
|
153
|
+
url: url2
|
|
154
|
+
});
|
|
155
|
+
event.unhandled = true;
|
|
156
|
+
});
|
|
159
157
|
throw e;
|
|
160
158
|
}
|
|
161
159
|
}
|
|
@@ -163,8 +161,28 @@ ${result.instructions}`
|
|
|
163
161
|
});
|
|
164
162
|
}
|
|
165
163
|
if (client.registerPrompts) {
|
|
166
|
-
client.registerPrompts((
|
|
167
|
-
return super.registerPrompt(
|
|
164
|
+
await client.registerPrompts((params, cb) => {
|
|
165
|
+
return super.registerPrompt(
|
|
166
|
+
this.getCapabilityName(client, params.title),
|
|
167
|
+
{
|
|
168
|
+
title: this.getCapabilityTitle(client, params.title),
|
|
169
|
+
description: params.description,
|
|
170
|
+
argsSchema: this.schemaToRawShape(params.argsSchema) || {}
|
|
171
|
+
},
|
|
172
|
+
async (args, extra) => {
|
|
173
|
+
try {
|
|
174
|
+
return await cb(args, extra);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
Bugsnag.notify(e, (event) => {
|
|
177
|
+
event.addMetadata("app", {
|
|
178
|
+
prompt: this.getCapabilityName(client, params.title)
|
|
179
|
+
});
|
|
180
|
+
event.unhandled = true;
|
|
181
|
+
});
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
);
|
|
168
186
|
});
|
|
169
187
|
}
|
|
170
188
|
}
|
|
@@ -198,19 +216,6 @@ ${result.instructions}`
|
|
|
198
216
|
};
|
|
199
217
|
return annotations;
|
|
200
218
|
}
|
|
201
|
-
getInputSchema(params) {
|
|
202
|
-
const args = {};
|
|
203
|
-
for (const param of params.parameters ?? []) {
|
|
204
|
-
args[param.name] = param.type;
|
|
205
|
-
if (param.description) {
|
|
206
|
-
args[param.name] = args[param.name].describe(param.description);
|
|
207
|
-
}
|
|
208
|
-
if (!param.required) {
|
|
209
|
-
args[param.name] = args[param.name].optional();
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return { ...args, ...this.schemaToRawShape(params.inputSchema) };
|
|
213
|
-
}
|
|
214
219
|
schemaToRawShape(schema) {
|
|
215
220
|
if (schema) {
|
|
216
221
|
if (schema instanceof ZodObject) {
|
|
@@ -228,34 +233,36 @@ ${result.instructions}`
|
|
|
228
233
|
}
|
|
229
234
|
return void 0;
|
|
230
235
|
}
|
|
231
|
-
|
|
232
|
-
return
|
|
236
|
+
getCapabilityTitle(client, title) {
|
|
237
|
+
return `${client.name}: ${title}`;
|
|
238
|
+
}
|
|
239
|
+
getCapabilityName(client, title) {
|
|
240
|
+
return `${client.capabilityPrefix}_${title.replace(/\s+/g, "_").toLowerCase()}`;
|
|
233
241
|
}
|
|
234
242
|
getDescription(params) {
|
|
235
243
|
const {
|
|
236
244
|
summary,
|
|
237
245
|
useCases,
|
|
238
246
|
examples,
|
|
239
|
-
parameters,
|
|
240
247
|
inputSchema,
|
|
241
248
|
hints,
|
|
242
249
|
outputDescription
|
|
243
250
|
} = params;
|
|
244
251
|
let description = summary;
|
|
245
|
-
if (
|
|
252
|
+
if (inputSchema && inputSchema instanceof ZodObject) {
|
|
253
|
+
let parameters = Object.keys(inputSchema.shape).map((key) => {
|
|
254
|
+
const field = inputSchema.shape[key];
|
|
255
|
+
const description2 = getTypeDescription(field);
|
|
256
|
+
const defaultValue = getDefaultValue(field);
|
|
257
|
+
return `- ${key} (${getReadableTypeName(field)})${isOptionalType(field) ? "" : " *required*"}${description2 ? `: ${description2}` : ""}${defaultValue !== null ? ` (default: ${JSON.stringify(defaultValue)})` : ""}`;
|
|
258
|
+
}).join("\n");
|
|
259
|
+
if (parameters.length === 0) {
|
|
260
|
+
parameters = "None";
|
|
261
|
+
}
|
|
246
262
|
description += `
|
|
247
263
|
|
|
248
264
|
**Parameters:**
|
|
249
|
-
${parameters
|
|
250
|
-
(p) => `- ${p.name} (${this.getReadableTypeName(p.type)})${p.required ? " *required*" : ""}${p.description ? `: ${p.description}` : ""}${p.examples ? ` (e.g. ${p.examples.join(", ")})` : ""}${p.constraints ? `
|
|
251
|
-
- ${p.constraints.join("\n - ")}` : ""}`
|
|
252
|
-
).join("\n")}`;
|
|
253
|
-
}
|
|
254
|
-
if (inputSchema && inputSchema instanceof ZodObject) {
|
|
255
|
-
description += "\n\n**Parameters:**\n";
|
|
256
|
-
description += Object.keys(inputSchema.shape).map(
|
|
257
|
-
(key) => this.formatParameterDescription(key, inputSchema.shape[key])
|
|
258
|
-
).join("\n");
|
|
265
|
+
${parameters}`;
|
|
259
266
|
}
|
|
260
267
|
if (outputDescription) {
|
|
261
268
|
description += `
|
|
@@ -286,50 +293,6 @@ Expected Output: ${ex.expectedOutput}` : ""}`
|
|
|
286
293
|
}
|
|
287
294
|
return description.trim();
|
|
288
295
|
}
|
|
289
|
-
formatParameterDescription(key, field, description = null, isOptional = false, defaultValue = null) {
|
|
290
|
-
description = description ?? (field.description || null);
|
|
291
|
-
if (field instanceof ZodOptional) {
|
|
292
|
-
field = field.unwrap();
|
|
293
|
-
return this.formatParameterDescription(
|
|
294
|
-
key,
|
|
295
|
-
field,
|
|
296
|
-
description,
|
|
297
|
-
true,
|
|
298
|
-
defaultValue
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
if (field instanceof ZodDefault) {
|
|
302
|
-
defaultValue = JSON.stringify(
|
|
303
|
-
field.def.defaultValue
|
|
304
|
-
);
|
|
305
|
-
field = field.unwrap();
|
|
306
|
-
return this.formatParameterDescription(
|
|
307
|
-
key,
|
|
308
|
-
field,
|
|
309
|
-
description,
|
|
310
|
-
true,
|
|
311
|
-
defaultValue
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
return `- ${key} (${this.getReadableTypeName(field)})${isOptional ? "" : " *required*"}${description ? `: ${description}` : ""}${defaultValue ? ` (default: ${defaultValue})` : ""}`;
|
|
315
|
-
}
|
|
316
|
-
getReadableTypeName(zodType) {
|
|
317
|
-
zodType = unwrapZodType(zodType);
|
|
318
|
-
if (zodType instanceof ZodRecord) {
|
|
319
|
-
const record = zodType;
|
|
320
|
-
return `record<${this.getReadableTypeName(record.def.keyType)}, ${this.getReadableTypeName(record.def.valueType)}>`;
|
|
321
|
-
}
|
|
322
|
-
if (zodType instanceof ZodString) return "string";
|
|
323
|
-
if (zodType instanceof ZodNumber) return "number";
|
|
324
|
-
if (zodType instanceof ZodBoolean) return "boolean";
|
|
325
|
-
if (zodType instanceof ZodArray) return "array";
|
|
326
|
-
if (zodType instanceof ZodObject) return "object";
|
|
327
|
-
if (zodType instanceof ZodEnum) return "enum";
|
|
328
|
-
if (zodType instanceof ZodLiteral) return "literal";
|
|
329
|
-
if (zodType instanceof ZodUnion) return "union";
|
|
330
|
-
if (zodType instanceof ZodAny) return "any";
|
|
331
|
-
return "any";
|
|
332
|
-
}
|
|
333
296
|
}
|
|
334
297
|
export {
|
|
335
298
|
SmartBearMcpServer
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 25e3;
|
|
2
|
+
function defaultTimeout() {
|
|
3
|
+
const fromEnv = process.env.MCP_SHUTDOWN_TIMEOUT_MS;
|
|
4
|
+
if (fromEnv) {
|
|
5
|
+
const parsed = Number.parseInt(fromEnv, 10);
|
|
6
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
7
|
+
return parsed;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return DEFAULT_TIMEOUT_MS;
|
|
11
|
+
}
|
|
12
|
+
class ShutdownManager {
|
|
13
|
+
handlers = [];
|
|
14
|
+
state = "idle";
|
|
15
|
+
signalsInstalled = false;
|
|
16
|
+
installedSignalListeners = [];
|
|
17
|
+
signals;
|
|
18
|
+
timeoutMs;
|
|
19
|
+
proc;
|
|
20
|
+
exitFn;
|
|
21
|
+
logger;
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.signals = options.signals ?? ["SIGTERM", "SIGINT"];
|
|
24
|
+
this.timeoutMs = options.timeoutMs ?? defaultTimeout();
|
|
25
|
+
this.proc = options.process ?? process;
|
|
26
|
+
this.exitFn = options.exit ?? ((code) => process.exit(code));
|
|
27
|
+
this.logger = options.logger ?? console;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Register a cleanup handler. Handlers run in LIFO order on shutdown,
|
|
31
|
+
* so subsystems registered later (closer to where they are used) tear
|
|
32
|
+
* down before subsystems registered earlier (closer to startup).
|
|
33
|
+
*/
|
|
34
|
+
register(name, fn) {
|
|
35
|
+
if (this.state !== "idle") {
|
|
36
|
+
this.logger.warn(
|
|
37
|
+
`[MCP][shutdown] Refusing to register handler "${name}" — already ${this.state}`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.handlers.push({ name, fn });
|
|
42
|
+
}
|
|
43
|
+
/** True from the moment the first shutdown signal is received. */
|
|
44
|
+
isDraining() {
|
|
45
|
+
return this.state !== "idle";
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Wire SIGTERM / SIGINT listeners. Idempotent — calling more than once
|
|
49
|
+
* is a no-op.
|
|
50
|
+
*/
|
|
51
|
+
installSignalHandlers() {
|
|
52
|
+
if (this.signalsInstalled) return;
|
|
53
|
+
this.signalsInstalled = true;
|
|
54
|
+
for (const signal of this.signals) {
|
|
55
|
+
const listener = () => {
|
|
56
|
+
this.handleSignal(signal);
|
|
57
|
+
};
|
|
58
|
+
this.proc.on(signal, listener);
|
|
59
|
+
this.installedSignalListeners.push({ signal, listener });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Tear down installed signal listeners (test-only). */
|
|
63
|
+
uninstallSignalHandlers() {
|
|
64
|
+
for (const { signal, listener } of this.installedSignalListeners) {
|
|
65
|
+
this.proc.off(signal, listener);
|
|
66
|
+
}
|
|
67
|
+
this.installedSignalListeners = [];
|
|
68
|
+
this.signalsInstalled = false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run all registered handlers in LIFO order with the configured deadline.
|
|
72
|
+
* Returns the drain result without exiting the process — exposed for tests
|
|
73
|
+
* and for callers that want to log / report before exiting.
|
|
74
|
+
*/
|
|
75
|
+
async drain() {
|
|
76
|
+
if (this.state === "complete") {
|
|
77
|
+
return { status: "clean", durationMs: 0, remainingHandlers: [] };
|
|
78
|
+
}
|
|
79
|
+
this.state = "draining";
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
const reversed = [...this.handlers].reverse();
|
|
82
|
+
const remaining = new Set(reversed.map((h) => h.name));
|
|
83
|
+
this.logger.log(
|
|
84
|
+
`[MCP][shutdown] Draining ${reversed.length} handler(s), deadline ${this.timeoutMs}ms`
|
|
85
|
+
);
|
|
86
|
+
let timeoutHandle;
|
|
87
|
+
const drainPromise = (async () => {
|
|
88
|
+
for (const h of reversed) {
|
|
89
|
+
try {
|
|
90
|
+
await h.fn();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
this.logger.error(
|
|
93
|
+
`[MCP][shutdown] Handler "${h.name}" threw during drain:`,
|
|
94
|
+
err
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
remaining.delete(h.name);
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
101
|
+
timeoutHandle = setTimeout(() => resolve("deadline"), this.timeoutMs);
|
|
102
|
+
timeoutHandle.unref?.();
|
|
103
|
+
});
|
|
104
|
+
const winner = await Promise.race([
|
|
105
|
+
drainPromise.then(() => "clean"),
|
|
106
|
+
timeoutPromise
|
|
107
|
+
]);
|
|
108
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
109
|
+
this.state = "complete";
|
|
110
|
+
const durationMs = Date.now() - start;
|
|
111
|
+
const remainingHandlers = [...remaining];
|
|
112
|
+
if (winner === "clean") {
|
|
113
|
+
this.logger.log(`[MCP][shutdown] Drained cleanly in ${durationMs}ms`);
|
|
114
|
+
} else {
|
|
115
|
+
this.logger.error(
|
|
116
|
+
`[MCP][shutdown] Deadline exceeded after ${durationMs}ms, ${remainingHandlers.length} handler(s) outstanding: ${remainingHandlers.join(", ") || "(none)"}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { status: winner, durationMs, remainingHandlers };
|
|
120
|
+
}
|
|
121
|
+
/** Exposed for tests; not part of the production call path. */
|
|
122
|
+
getState() {
|
|
123
|
+
return this.state;
|
|
124
|
+
}
|
|
125
|
+
handleSignal(signal) {
|
|
126
|
+
if (this.state === "draining") {
|
|
127
|
+
this.logger.warn(
|
|
128
|
+
`[MCP][shutdown] Received ${signal} while draining — forcing immediate exit(1)`
|
|
129
|
+
);
|
|
130
|
+
this.exitFn(1);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (this.state === "complete") {
|
|
134
|
+
this.exitFn(0);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.logger.log(`[MCP][shutdown] Received ${signal}, starting drain`);
|
|
138
|
+
this.drain().then((result) => {
|
|
139
|
+
this.exitFn(result.status === "clean" ? 0 : 1);
|
|
140
|
+
}).catch((err) => {
|
|
141
|
+
this.logger.error(
|
|
142
|
+
"[MCP][shutdown] Unexpected error from drain():",
|
|
143
|
+
err
|
|
144
|
+
);
|
|
145
|
+
this.exitFn(1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const shutdownManager = new ShutdownManager();
|
|
150
|
+
function registerShutdownHandler(name, fn) {
|
|
151
|
+
shutdownManager.register(name, fn);
|
|
152
|
+
}
|
|
153
|
+
function installSignalHandlers() {
|
|
154
|
+
shutdownManager.installSignalHandlers();
|
|
155
|
+
}
|
|
156
|
+
function isDraining() {
|
|
157
|
+
return shutdownManager.isDraining();
|
|
158
|
+
}
|
|
159
|
+
export {
|
|
160
|
+
ShutdownManager,
|
|
161
|
+
installSignalHandlers,
|
|
162
|
+
isDraining,
|
|
163
|
+
registerShutdownHandler,
|
|
164
|
+
shutdownManager
|
|
165
|
+
};
|