@convisoappsec/mcp 0.5.0 → 0.6.1

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.
@@ -7,17 +7,14 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
8
  import { z } from 'zod';
9
9
 
10
- import { FeedGateway } from './feed_gateway.js';
10
+ import { GraphQLClient } from './graphql_client.js';
11
+ import { listMutations, describeMutation } from './mutations.js';
11
12
  import pkg from '../../package.json' with { type: 'json' };
12
13
 
13
- const gateway = new FeedGateway();
14
-
15
14
  console.error('[+] Starting Conviso MCP Server (MCP SDK)');
16
15
 
17
- const server = new McpServer({
18
- name: pkg.name || 'conviso-mcp',
19
- version: pkg.version || '0.4.0',
20
- });
16
+ const BASE_URL = 'https://app.convisoappsec.com';
17
+ const gql = new GraphQLClient(`${BASE_URL}/graphql`, process.env.CONVISO_API_KEY || '');
21
18
 
22
19
  function sanitizeError(err, message = 'Request failed') {
23
20
  const error_id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
@@ -45,6 +42,9 @@ function sanitizeError(err, message = 'Request failed') {
45
42
  if (Array.isArray(err?.graphqlErrors) && err.graphqlErrors.length) {
46
43
  result.details = err.graphqlErrors;
47
44
  }
45
+ if (err?.authHint) {
46
+ result.hint = err.authHint;
47
+ }
48
48
  return result;
49
49
  }
50
50
 
@@ -59,260 +59,86 @@ function ok(data) {
59
59
  };
60
60
  }
61
61
 
62
- function fail(err, msg) {
63
- return ok(sanitizeError(err, msg));
64
- }
62
+ // Shared enum strings so tool descriptions state each list exactly once.
63
+ const SEVERITIES = 'NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL';
64
+ const ISSUE_STATUSES = 'CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION, FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED';
65
65
 
66
- // Annotation presets to keep tool definitions terse.
67
- const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
68
- const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
66
+ /**
67
+ * Build a fresh McpServer with all tools registered. stdio mode uses one instance for the
68
+ * whole session; HTTP mode builds one per request (the SDK's stateless pattern — reusing a
69
+ * single instance across concurrent transports leaks state between requests).
70
+ */
71
+ function buildServer() {
72
+ const server = new McpServer({
73
+ name: pkg.name || 'conviso-mcp',
74
+ version: pkg.version || '0.6.0',
75
+ });
69
76
 
70
- server.registerTool(
71
- 'get_companies',
72
- {
73
- description: 'Return a paginated list of companies accessible with the provided API key. search = name contains; label_eq = exact name match.',
74
- inputSchema: z.object({
77
+ // Registration helper: one place for the try/catch, error shape, and annotations.
78
+ function tool(name, { title, desc, schema, write = false, destructive = false, local = false }, handler) {
79
+ server.registerTool(
80
+ name,
81
+ {
82
+ description: desc,
83
+ inputSchema: schema,
84
+ annotations: {
85
+ title,
86
+ readOnlyHint: !write,
87
+ destructiveHint: destructive,
88
+ idempotentHint: !write,
89
+ openWorldHint: !local,
90
+ },
91
+ },
92
+ async (args) => {
93
+ try {
94
+ return ok(await handler(args));
95
+ } catch (err) {
96
+ return ok(sanitizeError(err, `${name} failed`));
97
+ }
98
+ }
99
+ );
100
+ }
101
+
102
+ /**
103
+ * READS — companies, issues, projects, assets, metrics
104
+ */
105
+
106
+ tool('get_companies', {
107
+ title: 'List Companies',
108
+ desc: 'List companies accessible with the API key. search = name contains; label_eq = exact name match.',
109
+ schema: z.object({
75
110
  page: z.number().optional(),
76
111
  limit: z.number().optional(),
77
112
  search: z.string().optional(),
78
113
  label_eq: z.string().optional(),
79
114
  }),
80
- annotations: {
81
- title: 'List Companies',
82
- readOnlyHint: true,
83
- destructiveHint: false,
84
- idempotentHint: true,
85
- openWorldHint: true,
86
- },
87
- },
88
- async ({ page = 1, limit = 10, search = '', label_eq = null }) => {
89
- try {
90
- return ok(await gateway.get_companies(page, limit, search, label_eq));
91
- } catch (err) {
92
- return fail(err, 'Failed to list companies');
93
- }
94
- }
95
- );
96
-
97
- server.registerTool(
98
- 'get_company_info',
99
- {
100
- description: 'Retrieve detailed information about a specific company, including plan, integrations, and branding metadata.',
101
- inputSchema: z.object({ company_id: z.number() }),
102
- annotations: {
103
- title: 'Company Details',
104
- readOnlyHint: true,
105
- destructiveHint: false,
106
- idempotentHint: true,
107
- openWorldHint: true,
108
- },
109
- },
110
- async ({ company_id }) => {
111
- try {
112
- return ok(await gateway.get_company_by_id(company_id));
113
- } catch (err) {
114
- return fail(err, 'Failed to get company info');
115
- }
116
- }
117
- );
118
-
119
- server.registerTool(
120
- 'get_issue',
121
- {
122
- description: 'Fetch detailed technical data for a specific vulnerability/issue. Optionally include raw request/response and vulnerable code snippets when `return_vulnerable_data` is true. WARNING: setting `return_vulnerable_data=true` may return sensitive data (exploit code, raw HTTP requests/responses, or secrets) — use with caution.',
123
- inputSchema: z.object({
115
+ }, ({ page = 1, limit = 10, search = '', label_eq = null }) =>
116
+ gql.get_companies(page, limit, search, label_eq));
117
+
118
+ tool('get_company_info', {
119
+ title: 'Company Details',
120
+ desc: 'Get a company by ID: plan, integrations, branding metadata.',
121
+ schema: z.object({ company_id: z.number() }),
122
+ }, ({ company_id }) => gql.get_company_by_id(company_id));
123
+
124
+ tool('get_issue', {
125
+ title: 'Issue Details',
126
+ desc: 'Get full technical detail for one issue/vulnerability. Set return_vulnerable_data=true to include raw requests/responses and vulnerable code snippets (may contain sensitive data).',
127
+ schema: z.object({
124
128
  id: z.number(),
125
129
  return_vulnerable_data: z.boolean().optional(),
126
130
  }),
127
- annotations: {
128
- title: 'Issue Details',
129
- readOnlyHint: true,
130
- destructiveHint: false,
131
- idempotentHint: true,
132
- openWorldHint: true,
133
- },
134
- },
135
- async ({ id, return_vulnerable_data }) => {
136
- try {
137
- return ok(await gateway.get_issue_by_id(id, return_vulnerable_data));
138
- } catch (err) {
139
- return fail(err, 'Failed to get issue details');
140
- }
141
- }
142
- );
143
-
144
- server.registerTool(
145
- 'get_issues',
146
- {
147
- description: `Get issues (vulnerabilities) for a company, with rich filtering and sorting.
131
+ }, ({ id, return_vulnerable_data }) => gql.get_issue_by_id(id, return_vulnerable_data));
148
132
 
149
- Filters (all optional):
150
- - search: substring match on issue title.
151
- - severities: any of NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL.
152
- - statuses: any of CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION,
153
- FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED.
154
- - sla_states: any of ON_TRACK, APPROACHING, BREACHED, RESOLVED, NOT_TRACKED, NOT_PARAMETERIZED.
155
- - created_after / created_before: ISO8601 dates (YYYY-MM-DD). For relative ranges
156
- ("last 30 days") call get_today_date first and compute the bounds.
157
- - assignee_emails: list of assignee emails.
158
- - project_id: restrict to one project. asset filtering: use get_issues_by_asset_id.
159
- - sort_by: one of RISK_SCORE, SEVERITY, ID, CREATED_AT, UPDATED_AT, SLA_DUE_AT. order: ASC or DESC.
160
- - extra_filters: dict mapping directly to IssuesFiltersInput for advanced keys, e.g.
161
- {"cves": [...], "categories": [...], "reachableBy": ["STATIC_ANALYSIS"],
162
- "businessImpact": ["HIGH"], "exploitability": "INTERNET_FACING",
163
- "compromisedEnvironment": true, "aiFpAnalyzed": true, "assetTags": [...]}.
164
- extra_filters values are sent as-is — omit a key rather than passing an empty list.
165
-
166
- Returns issue collection (id, title, severity, status, dates, sla, assignedUsers,
167
- asset, project) plus metadata (totalCount, totalPages, currentPage) for pagination.`,
168
- inputSchema: z.object({
169
- company_id: z.number(),
170
- page: z.number().optional(),
171
- limit: z.number().optional(),
172
- project_id: z.number().optional(),
173
- search: z.string().optional(),
174
- severities: z.array(z.string()).optional(),
175
- statuses: z.array(z.string()).optional(),
176
- sla_states: z.array(z.string()).optional(),
177
- created_after: z.string().optional(),
178
- created_before: z.string().optional(),
179
- assignee_emails: z.array(z.string()).optional(),
180
- sort_by: z.string().optional(),
181
- order: z.string().optional(),
182
- extra_filters: z.record(z.string(), z.any()).optional(),
183
- }),
184
- annotations: {
185
- title: 'List Issues',
186
- readOnlyHint: true,
187
- destructiveHint: false,
188
- idempotentHint: true,
189
- openWorldHint: true,
190
- },
191
- },
192
- async ({
193
- company_id, page = 1, limit = 10, project_id, search = '',
194
- severities, statuses, sla_states, created_after, created_before,
195
- assignee_emails, sort_by, order = 'DESC', extra_filters,
196
- }) => {
197
- try {
198
- return ok(await gateway.getIssues(company_id, {
199
- page,
200
- limit,
201
- projectId: project_id,
202
- search,
203
- severities,
204
- statuses,
205
- slaStates: sla_states,
206
- createdAfter: created_after,
207
- createdBefore: created_before,
208
- assigneeEmails: assignee_emails,
209
- sortBy: sort_by,
210
- order,
211
- extraFilters: extra_filters,
212
- }));
213
- } catch (err) {
214
- return fail(err, 'Failed to list issues');
215
- }
216
- }
217
- );
218
-
219
- server.registerTool(
220
- 'get_issues_by_asset_id',
221
- {
222
- description: `List vulnerabilities for a company filtered by a single asset ID, with rich filtering and sorting.
223
-
224
- Filters (all optional):
225
- - search: substring match on issue title.
226
- - severities: any of NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL.
227
- - statuses: any of CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION,
228
- FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED.
229
- - sla_states: any of ON_TRACK, APPROACHING, BREACHED, RESOLVED, NOT_TRACKED, NOT_PARAMETERIZED.
230
- - created_after / created_before: ISO8601 dates (YYYY-MM-DD). For relative ranges
231
- ("last 30 days") call get_today_date first and compute the bounds.
232
- - assignee_emails: list of assignee emails.
233
- - sort_by: one of RISK_SCORE, SEVERITY, ID, CREATED_AT, UPDATED_AT, SLA_DUE_AT. order: ASC or DESC.
234
- - extra_filters: dict mapping directly to IssuesFiltersInput for advanced keys, e.g.
235
- {"cves": [...], "categories": [...], "reachableBy": ["STATIC_ANALYSIS"],
236
- "businessImpact": ["HIGH"], "exploitability": "INTERNET_FACING",
237
- "compromisedEnvironment": true, "aiFpAnalyzed": true, "assetTags": [...]}.
238
-
239
- Returns issue collection (id, title, severity, status, dates, sla, assignedUsers,
240
- asset, project) plus metadata (totalCount, totalPages, currentPage) for pagination.`,
241
- inputSchema: z.object({
133
+ tool('get_issues', {
134
+ title: 'List Issues',
135
+ desc: `List a company's vulnerabilities with filtering and sorting. Optional: search (title substring), project_id, asset_id, severities (${SEVERITIES}), statuses (${ISSUE_STATUSES}), sla_states (ON_TRACK, APPROACHING, BREACHED, RESOLVED, NOT_TRACKED, NOT_PARAMETERIZED), created_after/created_before (YYYY-MM-DD — call get_today_date for relative ranges), assignee_emails, sort_by (RISK_SCORE, SEVERITY, ID, CREATED_AT, UPDATED_AT, SLA_DUE_AT) with order ASC|DESC, and extra_filters (raw IssuesFiltersInput keys, e.g. cves, categories, businessImpact — sent as-is). Returns collection + metadata (totalCount/totalPages) for pagination.`,
136
+ schema: z.object({
242
137
  company_id: z.number(),
243
- asset_id: z.number(),
244
- page: z.number().optional(),
245
- limit: z.number().optional(),
246
- search: z.string().optional(),
247
- severities: z.array(z.string()).optional(),
248
- statuses: z.array(z.string()).optional(),
249
- sla_states: z.array(z.string()).optional(),
250
- created_after: z.string().optional(),
251
- created_before: z.string().optional(),
252
- assignee_emails: z.array(z.string()).optional(),
253
- sort_by: z.string().optional(),
254
- order: z.string().optional(),
255
- extra_filters: z.record(z.string(), z.any()).optional(),
256
- }),
257
- annotations: {
258
- title: 'List Issues by Asset ID',
259
- readOnlyHint: true,
260
- destructiveHint: false,
261
- idempotentHint: true,
262
- openWorldHint: true,
263
- },
264
- },
265
- async ({
266
- company_id, asset_id, page = 1, limit = 10, search = '',
267
- severities, statuses, sla_states, created_after, created_before,
268
- assignee_emails, sort_by, order = 'DESC', extra_filters,
269
- }) => {
270
- try {
271
- const asset_ids = Array.isArray(asset_id) ? asset_id : [asset_id];
272
- return ok(await gateway.get_issues_by_asset_ids(company_id, page, limit, asset_ids, search, {
273
- severities,
274
- statuses,
275
- slaStates: sla_states,
276
- createdAfter: created_after,
277
- createdBefore: created_before,
278
- assigneeEmails: assignee_emails,
279
- sortBy: sort_by,
280
- order,
281
- extraFilters: extra_filters,
282
- }));
283
- } catch (err) {
284
- return fail(err, 'Failed to list issues by asset id');
285
- }
286
- }
287
- );
288
-
289
- server.registerTool(
290
- 'get_issues_by_project_id',
291
- {
292
- description: `List vulnerabilities for a company filtered by a project ID, with rich filtering and sorting.
293
-
294
- Filters (all optional):
295
- - search: substring match on issue title.
296
- - severities: any of NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL.
297
- - statuses: any of CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION,
298
- FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED.
299
- - sla_states: any of ON_TRACK, APPROACHING, BREACHED, RESOLVED, NOT_TRACKED, NOT_PARAMETERIZED.
300
- - created_after / created_before: ISO8601 dates (YYYY-MM-DD). For relative ranges
301
- ("last 30 days") call get_today_date first and compute the bounds.
302
- - assignee_emails: list of assignee emails.
303
- - sort_by: one of RISK_SCORE, SEVERITY, ID, CREATED_AT, UPDATED_AT, SLA_DUE_AT. order: ASC or DESC.
304
- - extra_filters: dict mapping directly to IssuesFiltersInput for advanced keys, e.g.
305
- {"cves": [...], "categories": [...], "reachableBy": ["STATIC_ANALYSIS"],
306
- "businessImpact": ["HIGH"], "exploitability": "INTERNET_FACING",
307
- "compromisedEnvironment": true, "aiFpAnalyzed": true, "assetTags": [...]}.
308
-
309
- Returns issue collection (id, title, severity, status, dates, sla, assignedUsers,
310
- asset, project) plus metadata (totalCount, totalPages, currentPage) for pagination.`,
311
- inputSchema: z.object({
312
- company_id: z.number(),
313
- project_id: z.number(),
314
138
  page: z.number().optional(),
315
139
  limit: z.number().optional(),
140
+ project_id: z.number().optional(),
141
+ asset_id: z.number().optional(),
316
142
  search: z.string().optional(),
317
143
  severities: z.array(z.string()).optional(),
318
144
  statuses: z.array(z.string()).optional(),
@@ -324,51 +150,31 @@ asset, project) plus metadata (totalCount, totalPages, currentPage) for paginati
324
150
  order: z.string().optional(),
325
151
  extra_filters: z.record(z.string(), z.any()).optional(),
326
152
  }),
327
- annotations: {
328
- title: 'List Issues by Project ID',
329
- readOnlyHint: true,
330
- destructiveHint: false,
331
- idempotentHint: true,
332
- openWorldHint: true,
333
- },
334
- },
335
- async ({
336
- company_id, project_id, page = 1, limit = 10, search = '',
153
+ }, ({
154
+ company_id, page = 1, limit = 10, project_id, asset_id, search = '',
337
155
  severities, statuses, sla_states, created_after, created_before,
338
156
  assignee_emails, sort_by, order = 'DESC', extra_filters,
339
- }) => {
340
- try {
341
- return ok(await gateway.getIssues(company_id, {
342
- page,
343
- limit,
344
- projectId: project_id,
345
- search,
346
- severities,
347
- statuses,
348
- slaStates: sla_states,
349
- createdAfter: created_after,
350
- createdBefore: created_before,
351
- assigneeEmails: assignee_emails,
352
- sortBy: sort_by,
353
- order,
354
- extraFilters: extra_filters,
355
- }));
356
- } catch (err) {
357
- return fail(err, 'Failed to list issues by project id');
358
- }
359
- }
360
- );
361
-
362
- server.registerTool(
363
- 'get_top_vulnerabilities',
364
- {
365
- description: 'Return a summary of vulnerability counts grouped by severity for a given company (risk overview). '
366
- + 'Optional filters (severities, statuses, asset_ids, asset_tags, created_after/created_before) narrow the '
367
- + 'overview; when none are set the response is identical to calling with no arguments at all. '
368
- + 'severities: NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL. statuses: CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, '
369
- + 'AWAITING_VALIDATION, FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED. created_after/created_before '
370
- + 'are ISO8601 dates (YYYY-MM-DD).',
371
- inputSchema: z.object({
157
+ }) => gql.getIssues(company_id, {
158
+ page,
159
+ limit,
160
+ projectId: project_id,
161
+ assetIds: asset_id ? [asset_id] : undefined,
162
+ search,
163
+ severities,
164
+ statuses,
165
+ slaStates: sla_states,
166
+ createdAfter: created_after,
167
+ createdBefore: created_before,
168
+ assigneeEmails: assignee_emails,
169
+ sortBy: sort_by,
170
+ order,
171
+ extraFilters: extra_filters,
172
+ }));
173
+
174
+ tool('get_top_vulnerabilities', {
175
+ title: 'Top Vulnerabilities',
176
+ desc: `Vulnerability counts grouped by title/severity for a company (risk overview). Optional filters: severities (${SEVERITIES}), statuses (${ISSUE_STATUSES}), asset_ids, asset_tags, created_after/created_before (YYYY-MM-DD).`,
177
+ schema: z.object({
372
178
  company_id: z.number(),
373
179
  severities: z.array(z.string()).optional(),
374
180
  statuses: z.array(z.string()).optional(),
@@ -377,44 +183,20 @@ server.registerTool(
377
183
  created_after: z.string().optional(),
378
184
  created_before: z.string().optional(),
379
185
  }),
380
- annotations: {
381
- title: 'Top Vulnerabilities',
382
- readOnlyHint: true,
383
- destructiveHint: false,
384
- idempotentHint: true,
385
- openWorldHint: true,
386
- },
387
- },
388
- async ({ company_id, severities, statuses, asset_ids, asset_tags, created_after, created_before }) => {
389
- try {
390
- return ok(await gateway.get_top_vulnerabilities(company_id, {
391
- severities,
392
- statuses,
393
- assetIds: asset_ids,
394
- assetTags: asset_tags,
395
- createdAfter: created_after,
396
- createdBefore: created_before,
397
- }));
398
- } catch (err) {
399
- return fail(err, 'Failed to get top vulnerabilities');
400
- }
401
- }
402
- );
403
-
404
- server.registerTool(
405
- 'get_projects',
406
- {
407
- description: `Return a paginated list of security projects for a company, with filtering and sorting. Defaults to 25 results per page to conserve tokens.
408
-
409
- Filters (all optional):
410
- - search: substring match on project label.
411
- - statuses: platform status labels (free text, e.g. "Fixing"), not an enum.
412
- - project_types: platform project type labels (free text, e.g. "Pentest"), not an enum.
413
- - created_after / created_before: ISO8601 dates (YYYY-MM-DD) bounding createdAt.
414
- - tags: list of project tags.
415
- - analyst_emails: list of allocated analyst emails.
416
- - sort_by: field to sort by (default "createdAt"). descending: sort direction (default true).`,
417
- inputSchema: z.object({
186
+ }, ({ company_id, severities, statuses, asset_ids, asset_tags, created_after, created_before }) =>
187
+ gql.get_top_vulnerabilities(company_id, {
188
+ severities,
189
+ statuses,
190
+ assetIds: asset_ids,
191
+ assetTags: asset_tags,
192
+ createdAfter: created_after,
193
+ createdBefore: created_before,
194
+ }));
195
+
196
+ tool('get_projects', {
197
+ title: 'List Projects',
198
+ desc: 'List a company\'s security projects (paginated, default 25/page). Optional: search (label substring), statuses and project_types (platform labels, free text e.g. "Fixing", "Pentest"), created_after/created_before (YYYY-MM-DD), tags, analyst_emails, sort_by (default createdAt) + descending.',
199
+ schema: z.object({
418
200
  company_id: z.number(),
419
201
  page: z.number().optional(),
420
202
  limit: z.number().optional(),
@@ -428,101 +210,49 @@ Filters (all optional):
428
210
  sort_by: z.string().optional(),
429
211
  descending: z.boolean().optional(),
430
212
  }),
431
- annotations: {
432
- title: 'List Projects',
433
- readOnlyHint: true,
434
- destructiveHint: false,
435
- idempotentHint: true,
436
- openWorldHint: true,
437
- },
438
- },
439
- async ({
213
+ }, ({
440
214
  company_id, page = 1, limit = 25, search = '', statuses, project_types,
441
215
  created_after, created_before, tags, analyst_emails, sort_by = 'createdAt',
442
216
  descending = true,
443
- }) => {
444
- try {
445
- return ok(await gateway.get_projects(company_id, page, limit, search, {
446
- statuses,
447
- projectTypes: project_types,
448
- createdAfter: created_after,
449
- createdBefore: created_before,
450
- tags,
451
- analystEmails: analyst_emails,
452
- sortBy: sort_by,
453
- descending,
454
- }));
455
- } catch (err) {
456
- return fail(err, 'Failed to list projects');
457
- }
458
- }
459
- );
460
-
461
- server.registerTool(
462
- 'get_project',
463
- {
464
- description: 'Retrieve detailed metadata for a specific project by its ID.',
465
- inputSchema: z.object({ project_id: z.number() }),
466
- annotations: {
467
- title: 'Project Details',
468
- readOnlyHint: true,
469
- destructiveHint: false,
470
- idempotentHint: true,
471
- openWorldHint: true,
472
- },
473
- },
474
- async ({ project_id }) => {
475
- try {
476
- return ok(await gateway.get_project_by_id(project_id));
477
- } catch (err) {
478
- return fail(err, 'Failed to get project');
479
- }
480
- }
481
- );
482
-
483
- server.registerTool(
484
- 'get_asset',
485
- {
486
- description: 'Fetch information about a specific asset by its ID.',
487
- inputSchema: z.object({ asset_id: z.number() }),
488
- annotations: {
489
- title: 'Asset Details',
490
- readOnlyHint: true,
491
- destructiveHint: false,
492
- idempotentHint: true,
493
- openWorldHint: true,
494
- },
495
- },
496
- async ({ asset_id }) => {
497
- try {
498
- return ok(await gateway.get_asset_by_id(asset_id));
499
- } catch (err) {
500
- return fail(err, 'Failed to get asset');
501
- }
502
- }
503
- );
504
-
505
- server.registerTool(
506
- 'get_assets',
507
- {
508
- description: `Return a paginated list of assets for a company, with rich filtering and sorting. Defaults to 25 results per page to reduce token usage.
509
-
510
- Filters (all optional):
511
- - name / search: substring match on asset name.
512
- - tags: list of asset tags.
513
- - technology: list of technologies.
514
- - business_impact: any of LOW, MEDIUM, HIGH, NOT_DEFINED.
515
- - exploitability: any of INTERNET_FACING, INTERNAL, NOT_DEFINED.
516
- - asset_type: asset type filter.
517
- - environment_compromised: boolean filter for compromised environment.
518
- - covered_by_scan: boolean filter for scan coverage.
519
- - sort_by: one of updated_at, name, business_impact, risk_score. order: ASC or DESC.
520
- - extra_filters: object mapping directly to AssetsSearch for advanced keys.
521
- extra_filters values are sent as-is — omit a key rather than passing an empty list.
522
-
523
- Returns asset collection (id, name, assetType, environment, audience, dates, riskScore)
524
- plus metadata (totalCount, totalPages, currentPage) for pagination.`,
525
- inputSchema: z.object({
217
+ }) => gql.get_projects(company_id, page, limit, search, {
218
+ statuses,
219
+ projectTypes: project_types,
220
+ createdAfter: created_after,
221
+ createdBefore: created_before,
222
+ tags,
223
+ analystEmails: analyst_emails,
224
+ sortBy: sort_by,
225
+ descending,
226
+ }));
227
+
228
+ tool('get_project', {
229
+ title: 'Project Details',
230
+ desc: 'Get a project by ID.',
231
+ schema: z.object({ project_id: z.number() }),
232
+ }, ({ project_id }) => gql.get_project_by_id(project_id));
233
+
234
+ tool('get_project_types', {
235
+ title: 'List Project Types',
236
+ desc: 'List the available project types (id, code, label, description). Use this to find the type_id required by create_project. Optional: search (label substring).',
237
+ schema: z.object({ search: z.string().optional() }),
238
+ }, ({ search }) => gql.get_project_types(search));
239
+
240
+ tool('get_project_statuses', {
241
+ title: 'List Project Statuses',
242
+ desc: 'List the available project statuses (id, label, isInitial). Use to find valid status values for project status updates.',
243
+ schema: z.object({}),
244
+ }, () => gql.get_project_statuses());
245
+
246
+ tool('get_asset', {
247
+ title: 'Asset Details',
248
+ desc: 'Get an asset by ID.',
249
+ schema: z.object({ asset_id: z.number() }),
250
+ }, ({ asset_id }) => gql.get_asset_by_id(asset_id));
251
+
252
+ tool('get_assets', {
253
+ title: 'List Assets',
254
+ desc: 'List a company\'s assets (paginated, default 25/page). Optional: name/search (substring), tags, technology, business_impact (LOW, MEDIUM, HIGH, NOT_DEFINED), exploitability (INTERNET_FACING, INTERNAL, NOT_DEFINED), asset_type, environment_compromised, covered_by_scan, sort_by (updated_at, name, business_impact, risk_score) + order, extra_filters (raw AssetsSearch keys). Returns collection + metadata.',
255
+ schema: z.object({
526
256
  company_id: z.number(),
527
257
  page: z.number().optional(),
528
258
  limit: z.number().optional(),
@@ -539,95 +269,45 @@ plus metadata (totalCount, totalPages, currentPage) for pagination.`,
539
269
  order: z.string().optional(),
540
270
  extra_filters: z.record(z.string(), z.any()).optional(),
541
271
  }),
542
- annotations: {
543
- title: 'List Assets',
544
- readOnlyHint: true,
545
- destructiveHint: false,
546
- idempotentHint: true,
547
- openWorldHint: true,
548
- },
549
- },
550
- async ({
272
+ }, ({
551
273
  company_id, page = 1, limit = 25, name, search, tags, technology,
552
274
  business_impact, exploitability, asset_type, environment_compromised,
553
275
  covered_by_scan, sort_by, order, extra_filters,
554
- }) => {
555
- try {
556
- return ok(await gateway.get_assets(company_id, page, limit, {
557
- name,
558
- search,
559
- tags,
560
- technology,
561
- businessImpact: business_impact,
562
- exploitability,
563
- assetType: asset_type,
564
- environmentCompromised: environment_compromised,
565
- coveredByScan: covered_by_scan,
566
- sortBy: sort_by,
567
- order,
568
- extraFilters: extra_filters,
569
- }));
570
- } catch (err) {
571
- return fail(err, 'Failed to list assets');
572
- }
573
- }
574
- );
575
-
576
- server.registerTool(
577
- 'create_project_url',
578
- {
579
- description: 'Return a direct URL to open a project in the Conviso Platform for quick navigation.',
580
- inputSchema: z.object({
581
- company_id: z.number(),
582
- project_id: z.number(),
583
- }),
584
- annotations: {
585
- title: 'Project URL Generator',
586
- readOnlyHint: true,
587
- destructiveHint: false,
588
- idempotentHint: true,
589
- openWorldHint: true,
590
- },
591
- },
592
- async ({ company_id, project_id }) => {
593
- try {
594
- return ok(await gateway.create_project_url(company_id, project_id));
595
- } catch (err) {
596
- return fail(err, 'Failed to create project URL');
597
- }
598
- }
599
- );
600
-
601
- server.registerTool(
602
- 'create_issue_url',
603
- {
604
- description: 'Return a direct URL to open a specific issue in the Conviso Platform for triage or review.',
605
- inputSchema: z.object({
606
- company_id: z.number(),
607
- issue_id: z.number(),
608
- }),
609
- annotations: {
610
- title: 'Issue URL Generator',
611
- readOnlyHint: true,
612
- destructiveHint: false,
613
- idempotentHint: true,
614
- openWorldHint: true,
615
- },
616
- },
617
- async ({ company_id, issue_id }) => {
618
- try {
619
- return ok(await gateway.create_issue_url(company_id, issue_id));
620
- } catch (err) {
621
- return fail(err, 'Failed to create issue URL');
622
- }
623
- }
624
- );
625
-
626
- server.registerTool(
627
- 'get_mttr_over_time',
628
- {
629
- description: 'Get Mean Time To Resolution (MTTR) aggregated over a date range. Supports filtering by severities, statuses, and assets.',
630
- inputSchema: z.object({
276
+ }) => gql.get_assets_by_company(company_id, page, limit, {
277
+ name,
278
+ search,
279
+ tags,
280
+ technology,
281
+ businessImpact: business_impact,
282
+ exploitability,
283
+ assetType: asset_type,
284
+ environmentCompromised: environment_compromised,
285
+ coveredByScan: covered_by_scan,
286
+ sortBy: sort_by,
287
+ order,
288
+ extraFilters: extra_filters,
289
+ }));
290
+
291
+ tool('create_project_url', {
292
+ title: 'Project URL',
293
+ desc: 'Build the direct Conviso Platform URL for a project.',
294
+ schema: z.object({ company_id: z.number(), project_id: z.number() }),
295
+ local: true,
296
+ }, ({ company_id, project_id }) =>
297
+ `${BASE_URL}/spa/company/${company_id}/projects/${project_id}`);
298
+
299
+ tool('create_issue_url', {
300
+ title: 'Issue URL',
301
+ desc: 'Build the direct Conviso Platform URL for an issue.',
302
+ schema: z.object({ company_id: z.number(), issue_id: z.number() }),
303
+ local: true,
304
+ }, ({ company_id, issue_id }) =>
305
+ `${BASE_URL}/spa/company/${company_id}/vulnerabilities?title=&search=${issue_id}`);
306
+
307
+ tool('get_mttr_over_time', {
308
+ title: 'MTTR Over Time',
309
+ desc: 'Mean Time To Resolution over a date range, broken down by severity. Optional filters: severities, statuses, asset_ids, asset_tags.',
310
+ schema: z.object({
631
311
  company_id: z.number(),
632
312
  start_date: z.string(),
633
313
  end_date: z.string(),
@@ -636,208 +316,220 @@ server.registerTool(
636
316
  asset_ids: z.array(z.number()).optional(),
637
317
  asset_tags: z.array(z.string()).optional(),
638
318
  }),
639
- annotations: {
640
- title: 'MTTR Over Time',
641
- readOnlyHint: true,
642
- destructiveHint: false,
643
- idempotentHint: true,
644
- openWorldHint: true,
645
- },
646
- },
647
- async (args) => {
648
- try {
649
- return ok(await gateway.get_mttr_over_time(
650
- args.company_id,
651
- args.start_date,
652
- args.end_date,
653
- args.severities,
654
- args.statuses,
655
- args.asset_ids,
656
- args.asset_tags
657
- ));
658
- } catch (err) {
659
- return fail(err, 'Failed to get MTTR metrics');
660
- }
661
- }
662
- );
319
+ }, (a) => gql.get_mttr_over_time(a.company_id, a.start_date, a.end_date, a.severities, a.statuses, a.asset_ids, a.asset_tags));
320
+
321
+ tool('get_overall_risk_score_history', {
322
+ title: 'Risk Score History',
323
+ desc: 'Historical overall risk score for a company (current value + difference from last period).',
324
+ schema: z.object({ company_id: z.number() }),
325
+ }, ({ company_id }) => gql.get_overall_risk_score_history(company_id));
326
+
327
+ tool('get_today_date', {
328
+ title: 'Get Today Date',
329
+ desc: 'Current day/month/year — use to compute relative date ranges before filtering by dates.',
330
+ schema: z.object({}),
331
+ local: true,
332
+ }, () => {
333
+ const d = new Date();
334
+ return { day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() };
335
+ });
336
+
337
+ /**
338
+ * READS tickets, requirements, applications, scans, supply chain, AI-pentest, threat modeling
339
+ */
663
340
 
664
- server.registerTool(
665
- 'get_overall_risk_score_history',
666
- {
667
- description: 'Retrieve historical overall risk scores for a company, useful for trend analysis and reporting.',
668
- inputSchema: z.object({
341
+ tool('get_tickets', {
342
+ title: 'List Tickets',
343
+ desc: 'List a company\'s tickets (paginated). Optional: search, sort_by + descending, params (raw TicketSearch keys: types, statuses, priorities, impacts, tags, mineOnly...).',
344
+ schema: z.object({
669
345
  company_id: z.number(),
346
+ page: z.number().optional(),
347
+ limit: z.number().optional(),
348
+ search: z.string().optional(),
349
+ sort_by: z.string().optional(),
350
+ descending: z.boolean().optional(),
351
+ params: z.record(z.string(), z.any()).optional(),
670
352
  }),
671
- annotations: {
672
- title: 'Risk Score History',
673
- readOnlyHint: true,
674
- destructiveHint: false,
675
- idempotentHint: true,
676
- openWorldHint: true,
677
- },
678
- },
679
- async ({ company_id }) => {
680
- try {
681
- return ok(await gateway.get_overall_risk_score_history(company_id));
682
- } catch (err) {
683
- return fail(err, 'Failed to get risk score history');
684
- }
685
- }
686
- );
687
-
688
- server.registerTool(
689
- 'get_today_date',
690
- {
691
- description: 'Utility tool returning the current date.',
692
- inputSchema: z.object({}),
693
- annotations: {
694
- title: 'Get Today Date',
695
- readOnlyHint: true,
696
- destructiveHint: false,
697
- idempotentHint: true,
698
- openWorldHint: false,
699
- },
700
- },
701
- async () => {
702
- try {
703
- const d = new Date();
704
- return ok({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() });
705
- } catch (err) {
706
- return fail(err, 'Failed to get current date');
707
- }
708
- }
709
- );
710
-
711
- /**
712
- * MUTATIONS generic catalog-driven engine (covers the allowlisted platform mutations;
713
- * see operation_allowlist.js)
714
- */
715
-
716
- server.registerTool(
717
- 'list_mutations',
718
- {
719
- description: `Discover available Conviso Platform mutations (write operations). Returns name, description, category and a destructive flag. This is step 1 of the write workflow: list_mutations (discover) -> describe_mutation (get the input schema) -> execute_mutation (run it).
353
+ }, ({ company_id, page, limit, search, sort_by, descending, params }) =>
354
+ gql.get_tickets(company_id, { page, limit, search, sort_by, descending, params }));
355
+
356
+ tool('get_ticket', {
357
+ title: 'Ticket Details',
358
+ desc: 'Get a ticket by ID (status, priority, impact, assignee).',
359
+ schema: z.object({ company_id: z.number(), ticket_id: z.number() }),
360
+ }, ({ company_id, ticket_id }) => gql.get_ticket(company_id, ticket_id));
361
+
362
+ tool('get_requirements', {
363
+ title: 'List Requirements',
364
+ desc: 'List security requirements/checklists for a scope (company) id, paginated. Optional: filters (raw RequirementsFilterInput).',
365
+ schema: z.object({
366
+ scope_id: z.number(),
367
+ page: z.number().optional(),
368
+ limit: z.number().optional(),
369
+ filters: z.record(z.string(), z.any()).optional(),
370
+ }),
371
+ }, ({ scope_id, page, limit, filters }) => gql.get_requirements(scope_id, { page, limit, filters }));
372
+
373
+ tool('get_requirement', {
374
+ title: 'Requirement Details',
375
+ desc: 'Get a requirement/checklist by ID.',
376
+ schema: z.object({ company_id: z.number(), requirement_id: z.number() }),
377
+ }, ({ company_id, requirement_id }) => gql.get_requirement(company_id, requirement_id));
378
+
379
+ tool('get_project_requirements', {
380
+ title: 'Project Requirements',
381
+ desc: 'List the requirements/checklists attached to a project.',
382
+ schema: z.object({ project_id: z.number() }),
383
+ }, ({ project_id }) => gql.get_project_requirements(project_id));
384
+
385
+ tool('get_applications', {
386
+ title: 'List Applications',
387
+ desc: 'List a company\'s applications (name, url, riskScore, assetsCount). Optional: search by name.',
388
+ schema: z.object({ company_id: z.number(), search: z.string().optional() }),
389
+ }, ({ company_id, search }) => gql.get_applications(company_id, search));
390
+
391
+ tool('get_application', {
392
+ title: 'Application Details',
393
+ desc: 'Get an application by ID, including its linked assets.',
394
+ schema: z.object({ company_id: z.number(), application_id: z.number() }),
395
+ }, ({ company_id, application_id }) => gql.get_application(company_id, application_id));
396
+
397
+ tool('get_scan_histories', {
398
+ title: 'List Scan Histories',
399
+ desc: 'List scan executions for a company (status, integration, duration, vulnerability counts). Optional: asset_ids, filters (raw ScansHistoriesFiltersInput).',
400
+ schema: z.object({
401
+ company_id: z.number(),
402
+ asset_ids: z.array(z.number()).optional(),
403
+ page: z.number().optional(),
404
+ limit: z.number().optional(),
405
+ filters: z.record(z.string(), z.any()).optional(),
406
+ }),
407
+ }, ({ company_id, asset_ids, page, limit, filters }) =>
408
+ gql.get_scan_histories(company_id, { assetIds: asset_ids, page, limit, filters }));
409
+
410
+ tool('get_asset_scans_count', {
411
+ title: 'Asset Scans Count',
412
+ desc: 'Scan coverage for a company: assets with/without scans and which scan types count.',
413
+ schema: z.object({ company_id: z.number() }),
414
+ }, ({ company_id }) => gql.get_asset_scans_count(company_id));
415
+
416
+ tool('get_sbom_components', {
417
+ title: 'List SBOM Components',
418
+ desc: 'List SBOM / supply-chain components (name, version, technology, package manager, license, issues by severity). Optional: search (raw SbomComponentSearchInput).',
419
+ schema: z.object({
420
+ company_id: z.number(),
421
+ page: z.number().optional(),
422
+ limit: z.number().optional(),
423
+ search: z.record(z.string(), z.any()).optional(),
424
+ }),
425
+ }, ({ company_id, page, limit, search }) => gql.get_sbom_components(company_id, { page, limit, search }));
720
426
 
721
- Filters (all optional):
722
- - search: case-insensitive substring matched against mutation name and description.
723
- - category: one of issue, ticket, project, asset, requirement, pentest, application, threat_model.
724
- - limit: max rows to return (default 50).`,
725
- inputSchema: z.object({
427
+ tool('get_pentest_artifacts', {
428
+ title: 'List Pentest Artifacts',
429
+ desc: 'List AI-Pentest artifacts (label, type, scheduling, latest execution). Optional: search, assignee_email, pentest_type, application_id.',
430
+ schema: z.object({
431
+ company_id: z.number(),
432
+ page: z.number().optional(),
433
+ limit: z.number().optional(),
726
434
  search: z.string().optional(),
727
- category: z.string().optional(),
435
+ assignee_email: z.string().optional(),
436
+ pentest_type: z.string().optional(),
437
+ application_id: z.number().optional(),
438
+ }),
439
+ }, ({ company_id, page, limit, search, assignee_email, pentest_type, application_id }) =>
440
+ gql.get_pentest_artifacts(company_id, {
441
+ page, limit, search, assigneeEmail: assignee_email, pentestType: pentest_type, applicationId: application_id,
442
+ }));
443
+
444
+ tool('get_pentest_artifact', {
445
+ title: 'Pentest Artifact Details',
446
+ desc: 'Get an AI-Pentest artifact by ID, including scope and executions.',
447
+ schema: z.object({ artifact_id: z.number() }),
448
+ }, ({ artifact_id }) => gql.get_pentest_artifact(artifact_id));
449
+
450
+ tool('get_pentest_execution', {
451
+ title: 'Pentest Execution Result',
452
+ desc: 'Get an AI-Pentest execution by ID: status, vulnerability count, severity breakdown, retest progress.',
453
+ schema: z.object({ execution_id: z.number() }),
454
+ }, ({ execution_id }) => gql.get_pentest_execution(execution_id));
455
+
456
+ tool('get_threat_model_artifacts', {
457
+ title: 'List Threat Model Artifacts',
458
+ desc: 'List Threat Modeling artifacts (label, scope, latest version). Optional: search, assignee_email, has_version.',
459
+ schema: z.object({
460
+ company_id: z.number(),
461
+ page: z.number().optional(),
728
462
  limit: z.number().optional(),
463
+ search: z.string().optional(),
464
+ assignee_email: z.string().optional(),
465
+ has_version: z.boolean().optional(),
729
466
  }),
730
- annotations: {
731
- title: 'List Mutations',
732
- readOnlyHint: true,
733
- destructiveHint: false,
734
- idempotentHint: true,
735
- openWorldHint: false,
736
- },
737
- },
738
- async ({ search, category, limit }) => {
739
- try {
740
- return ok(gateway.list_mutations({ search, category, limit }));
741
- } catch (err) {
742
- return fail(err, 'Failed to list mutations');
743
- }
744
- }
745
- );
746
-
747
- server.registerTool(
748
- 'describe_mutation',
749
- {
750
- description: `Return the full input schema for a single mutation: argument list, every input field (with type, whether it is required, descriptions, allowed enum values, and nested input objects expanded), plus the default fields returned by execute_mutation. Use this before execute_mutation to build a valid 'variables.input' object.`,
751
- inputSchema: z.object({
752
- name: z.string(),
467
+ }, ({ company_id, page, limit, search, assignee_email, has_version }) =>
468
+ gql.get_threat_model_artifacts(company_id, {
469
+ page, limit, search, assigneeEmail: assignee_email, hasVersion: has_version,
470
+ }));
471
+
472
+ tool('get_threat_model_artifact', {
473
+ title: 'Threat Model Artifact Details',
474
+ desc: 'Get a Threat Modeling artifact by ID, including its versions (diagrams, notes, scope).',
475
+ schema: z.object({ artifact_id: z.number() }),
476
+ }, ({ artifact_id }) => gql.get_threat_model_artifact(artifact_id));
477
+
478
+ /**
479
+ * MUTATIONS — engine (discover -> describe -> execute over the allowlist)
480
+ */
481
+
482
+ tool('list_mutations', {
483
+ title: 'List Mutations',
484
+ desc: 'Discover the permitted write operations (name, description, category, destructive flag). Step 1 of the write workflow: list_mutations -> describe_mutation -> execute_mutation. Optional: search (substring), category (issue, ticket, project, asset, requirement, pentest, application, threat_model), limit (default 50).',
485
+ schema: z.object({
486
+ search: z.string().optional(),
487
+ category: z.string().optional(),
488
+ limit: z.number().optional(),
753
489
  }),
754
- annotations: {
755
- title: 'Describe Mutation',
756
- readOnlyHint: true,
757
- destructiveHint: false,
758
- idempotentHint: true,
759
- openWorldHint: false,
760
- },
761
- },
762
- async ({ name }) => {
763
- try {
764
- return ok(gateway.describe_mutation(name));
765
- } catch (err) {
766
- return fail(err, 'Failed to describe mutation');
767
- }
768
- }
769
- );
770
-
771
- server.registerTool(
772
- 'execute_mutation',
773
- {
774
- description: `Execute any allowlisted Conviso Platform mutation by name (use list_mutations to see the full set). Covers writes for issues/vulnerabilities, assets (+ DAST), tickets, projects, requirements, AI-pentest, applications and threat modeling.
775
-
776
- Usage: call describe_mutation(name) first to learn the input shape, then pass it here. Every mutation takes a single input object, so variables must be { "input": { ...fields... } }.
777
-
778
- - name: the mutation name (e.g. "changeIssueStatus", "createProject", "deleteAsset").
779
- - variables: GraphQL variables, normally { input: { ... } }.
780
- - return_fields: optional raw GraphQL selection set to override the default returned fields.
781
-
782
- WARNING: this performs writes and can be destructive (the catalog marks delete/bulk/remove/cancel/revoke operations as destructive). Confirm intent before running delete or bulk mutations.`,
783
- inputSchema: z.object({
490
+ local: true,
491
+ }, ({ search, category, limit }) => listMutations({ search, category, limit }));
492
+
493
+ tool('describe_mutation', {
494
+ title: 'Describe Mutation',
495
+ desc: 'Full input schema for one mutation: fields with types, required flags, enum values, nested inputs, plus the default returned fields. Call before execute_mutation.',
496
+ schema: z.object({ name: z.string() }),
497
+ local: true,
498
+ }, ({ name }) => describeMutation(name));
499
+
500
+ tool('execute_mutation', {
501
+ title: 'Execute Mutation',
502
+ desc: 'Run any permitted write operation by name (see list_mutations). variables is the mutation input — pass { input: {...} } or the input fields directly (auto-wrapped). Optional return_fields overrides the returned selection set. WARNING: performs writes; delete/bulk operations are destructive — confirm intent first.',
503
+ schema: z.object({
784
504
  name: z.string(),
785
505
  variables: z.record(z.string(), z.any()).optional(),
786
506
  return_fields: z.string().optional(),
787
507
  }),
788
- annotations: {
789
- title: 'Execute Mutation',
790
- readOnlyHint: false,
791
- destructiveHint: true,
792
- idempotentHint: false,
793
- openWorldHint: true,
794
- },
795
- },
796
- async ({ name, variables = {}, return_fields = null }) => {
797
- try {
798
- return ok(await gateway.execute_mutation(name, variables, return_fields));
799
- } catch (err) {
800
- return fail(err, 'Failed to execute mutation');
801
- }
802
- }
803
- );
804
-
805
- /**
806
- * MUTATIONS — curated typed shortcuts for the most common writes
807
- */
808
-
809
- server.registerTool(
810
- 'change_issue_status',
811
- {
812
- description: `Change the status of an issue/vulnerability. status must be one of CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION, FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED. Pass 'extra' for advanced ChangeIssueStatusInput fields (e.g. riskAcceptedUntil, externalAuthorIdentifier).`,
813
- inputSchema: z.object({
508
+ write: true,
509
+ destructive: true,
510
+ }, ({ name, variables = {}, return_fields = null }) =>
511
+ gql.executeMutation(name, variables, return_fields));
512
+
513
+ /**
514
+ * MUTATIONS — typed shortcuts for the most common writes
515
+ */
516
+
517
+ tool('change_issue_status', {
518
+ title: 'Change Issue Status',
519
+ desc: `Change an issue's status. status: one of ${ISSUE_STATUSES}. Optional reason; extra = advanced ChangeIssueStatusInput fields (e.g. riskAcceptedUntil).`,
520
+ schema: z.object({
814
521
  issue_id: z.number(),
815
522
  status: z.string(),
816
523
  reason: z.string().optional(),
817
524
  extra: z.record(z.string(), z.any()).optional(),
818
525
  }),
819
- annotations: {
820
- title: 'Change Issue Status',
821
- readOnlyHint: false,
822
- destructiveHint: false,
823
- idempotentHint: false,
824
- openWorldHint: true,
825
- },
826
- },
827
- async ({ issue_id, status, reason, extra }) => {
828
- try {
829
- return ok(await gateway.change_issue_status({ issue_id, status, reason, extra }));
830
- } catch (err) {
831
- return fail(err, 'Failed to change issue status');
832
- }
833
- }
834
- );
526
+ write: true,
527
+ }, (a) => gql.change_issue_status(a));
835
528
 
836
- server.registerTool(
837
- 'create_source_code_vulnerability',
838
- {
839
- description: `Create a source-code (SAST-style) vulnerability/issue on an asset. severity is one of NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL; impact_level and probability_level are LOW, MEDIUM or HIGH (default MEDIUM); status defaults to DRAFT. Pass 'extra' for any other CreateSourceCodeVulnerabilityInput field.`,
840
- inputSchema: z.object({
529
+ tool('create_source_code_vulnerability', {
530
+ title: 'Create Source Code Vulnerability',
531
+ desc: `Create a manual source-code (SAST-style) vulnerability on an asset. severity: ${SEVERITIES}. impact_level/probability_level: LOW, MEDIUM, HIGH (default MEDIUM). status defaults to DRAFT. extra = any other CreateSourceCodeVulnerabilityInput field.`,
532
+ schema: z.object({
841
533
  asset_id: z.number(),
842
534
  title: z.string(),
843
535
  description: z.string(),
@@ -862,28 +554,13 @@ server.registerTool(
862
554
  patterns: z.array(z.string()).optional(),
863
555
  extra: z.record(z.string(), z.any()).optional(),
864
556
  }),
865
- annotations: {
866
- title: 'Create Source Code Vulnerability',
867
- readOnlyHint: false,
868
- destructiveHint: false,
869
- idempotentHint: false,
870
- openWorldHint: true,
871
- },
872
- },
873
- async (args) => {
874
- try {
875
- return ok(await gateway.create_source_code_vulnerability(args));
876
- } catch (err) {
877
- return fail(err, 'Failed to create source code vulnerability');
878
- }
879
- }
880
- );
557
+ write: true,
558
+ }, (a) => gql.create_source_code_vulnerability(a));
881
559
 
882
- server.registerTool(
883
- 'create_project',
884
- {
885
- description: `Create a project. Required: company_id, type_id (project type id), label, goal, scope, start_date (YYYY-MM-DD). Optional: end_date. Pass 'extra' for advanced CreateProjectInput fields (e.g. assetsIds, tags, allocatedPortalUserEmails, playbooksIds).`,
886
- inputSchema: z.object({
560
+ tool('create_project', {
561
+ title: 'Create Project',
562
+ desc: 'Create a project. Required: company_id, type_id (call get_project_types to find it), label, goal, scope, start_date (YYYY-MM-DD). Optional: end_date; extra = advanced CreateProjectInput fields (assetsIds, tags, allocatedPortalUserEmails...).',
563
+ schema: z.object({
887
564
  company_id: z.number(),
888
565
  type_id: z.number(),
889
566
  label: z.string(),
@@ -893,28 +570,13 @@ server.registerTool(
893
570
  end_date: z.string().optional(),
894
571
  extra: z.record(z.string(), z.any()).optional(),
895
572
  }),
896
- annotations: {
897
- title: 'Create Project',
898
- readOnlyHint: false,
899
- destructiveHint: false,
900
- idempotentHint: false,
901
- openWorldHint: true,
902
- },
903
- },
904
- async (args) => {
905
- try {
906
- return ok(await gateway.create_project(args));
907
- } catch (err) {
908
- return fail(err, 'Failed to create project');
909
- }
910
- }
911
- );
573
+ write: true,
574
+ }, (a) => gql.create_project(a));
912
575
 
913
- server.registerTool(
914
- 'create_asset',
915
- {
916
- description: `Create an asset. Required: company_id, name. Optional: asset_type, url, description, business_impact (LOW/MEDIUM/HIGH/NOT_DEFINED), exploitability (INTERNET_FACING/INTERNAL/NOT_DEFINED), tags (string list). Pass 'extra' for advanced CreateAssetInput fields.`,
917
- inputSchema: z.object({
576
+ tool('create_asset', {
577
+ title: 'Create Asset',
578
+ desc: 'Create an asset. Required: company_id, name. Optional: asset_type, url, description, business_impact (LOW, MEDIUM, HIGH, NOT_DEFINED), exploitability (INTERNET_FACING, INTERNAL, NOT_DEFINED), tags; extra = advanced CreateAssetInput fields.',
579
+ schema: z.object({
918
580
  company_id: z.number(),
919
581
  name: z.string(),
920
582
  asset_type: z.string().optional(),
@@ -925,28 +587,13 @@ server.registerTool(
925
587
  tags: z.array(z.string()).optional(),
926
588
  extra: z.record(z.string(), z.any()).optional(),
927
589
  }),
928
- annotations: {
929
- title: 'Create Asset',
930
- readOnlyHint: false,
931
- destructiveHint: false,
932
- idempotentHint: false,
933
- openWorldHint: true,
934
- },
935
- },
936
- async (args) => {
937
- try {
938
- return ok(await gateway.create_asset(args));
939
- } catch (err) {
940
- return fail(err, 'Failed to create asset');
941
- }
942
- }
943
- );
590
+ write: true,
591
+ }, (a) => gql.create_asset(a));
944
592
 
945
- server.registerTool(
946
- 'create_ticket',
947
- {
948
- description: `Open a ticket. Required: company_id, type (BUG, FEATURE_REQUEST, PROJECT_REQUEST, SUPPORT_REQUEST), title, description. Optional: priority (P1/P2/P3), impact (LOW/MEDIUM/HIGH). Pass 'extra' for advanced CreateTicketInput fields.`,
949
- inputSchema: z.object({
593
+ tool('create_ticket', {
594
+ title: 'Create Ticket',
595
+ desc: 'Open a ticket. Required: company_id, type (BUG, FEATURE_REQUEST, PROJECT_REQUEST, SUPPORT_REQUEST), title, description. Optional: priority (P1, P2, P3), impact (LOW, MEDIUM, HIGH); extra = advanced CreateTicketInput fields.',
596
+ schema: z.object({
950
597
  company_id: z.number(),
951
598
  type: z.string(),
952
599
  title: z.string(),
@@ -955,64 +602,27 @@ server.registerTool(
955
602
  impact: z.string().optional(),
956
603
  extra: z.record(z.string(), z.any()).optional(),
957
604
  }),
958
- annotations: {
959
- title: 'Create Ticket',
960
- readOnlyHint: false,
961
- destructiveHint: false,
962
- idempotentHint: false,
963
- openWorldHint: true,
964
- },
965
- },
966
- async (args) => {
967
- try {
968
- return ok(await gateway.create_ticket(args));
969
- } catch (err) {
970
- return fail(err, 'Failed to create ticket');
971
- }
972
- }
973
- );
974
-
975
- /**
976
- * CURATED WRITES — DAST + AI-Pentest shortcuts
977
- */
978
-
979
- server.registerTool(
980
- 'run_dast',
981
- {
982
- description: 'Start a Conviso DAST scan on an asset (startConvisoDast). Required: asset_id.',
983
- inputSchema: z.object({ asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
984
- annotations: { title: 'Run DAST', ...WRITE },
985
- },
986
- async ({ asset_id, extra }) => {
987
- try {
988
- return ok(await gateway.run_dast({ asset_id, extra }));
989
- } catch (err) {
990
- return fail(err, 'Failed to start DAST');
991
- }
992
- }
993
- );
994
-
995
- server.registerTool(
996
- 'trigger_pentest',
997
- {
998
- description: 'Trigger an AI-Pentest execution from an existing pentest artifact (createPentestExecution). Required: artifact_id.',
999
- inputSchema: z.object({ artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
1000
- annotations: { title: 'Trigger AI-Pentest', ...WRITE },
1001
- },
1002
- async ({ artifact_id, extra }) => {
1003
- try {
1004
- return ok(await gateway.trigger_pentest({ artifact_id, extra }));
1005
- } catch (err) {
1006
- return fail(err, 'Failed to trigger pentest');
1007
- }
1008
- }
1009
- );
1010
-
1011
- server.registerTool(
1012
- 'create_pentest_artifact',
1013
- {
1014
- description: 'Create an AI-Pentest artifact (the scope/config a pentest runs against). Required: company_id, application_id, label, pentest_type. Optional: description, scope_text, assignee_email, domains, in_scope, out_scope. Pass \'extra\' for advanced fields (scheduling, repositories, documentation, size/depth).',
1015
- inputSchema: z.object({
605
+ write: true,
606
+ }, (a) => gql.create_ticket(a));
607
+
608
+ tool('run_dast', {
609
+ title: 'Run DAST',
610
+ desc: 'Start a Conviso DAST scan on an asset (startConvisoDast). Required: asset_id.',
611
+ schema: z.object({ asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
612
+ write: true,
613
+ }, (a) => gql.run_dast(a));
614
+
615
+ tool('trigger_pentest', {
616
+ title: 'Trigger AI-Pentest',
617
+ desc: 'Trigger an AI-Pentest execution from an existing pentest artifact (createPentestExecution). Required: artifact_id.',
618
+ schema: z.object({ artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
619
+ write: true,
620
+ }, (a) => gql.trigger_pentest(a));
621
+
622
+ tool('create_pentest_artifact', {
623
+ title: 'Create Pentest Artifact',
624
+ desc: 'Create an AI-Pentest artifact (the scope/config a pentest runs against). Required: company_id, application_id, label, pentest_type. Optional: description, scope_text, assignee_email, domains, in_scope, out_scope; extra = advanced fields (scheduling, repositories, documentation, size/depth).',
625
+ schema: z.object({
1016
626
  company_id: z.number(),
1017
627
  application_id: z.number(),
1018
628
  label: z.string(),
@@ -1025,303 +635,11 @@ server.registerTool(
1025
635
  out_scope: z.array(z.string()).optional(),
1026
636
  extra: z.record(z.string(), z.any()).optional(),
1027
637
  }),
1028
- annotations: { title: 'Create Pentest Artifact', ...WRITE },
1029
- },
1030
- async (args) => {
1031
- try {
1032
- return ok(await gateway.create_pentest_artifact(args));
1033
- } catch (err) {
1034
- return fail(err, 'Failed to create pentest artifact');
1035
- }
1036
- }
1037
- );
1038
-
1039
- /**
1040
- * READS — Tickets, Requirements, Applications, Scans, Supply chain, AI-Pentest, Threat Modeling
1041
- */
1042
-
1043
- server.registerTool(
1044
- 'get_tickets',
1045
- {
1046
- description: 'List tickets for a company (paginated). Optional: search (title/description), sort_by, descending, and params (TicketSearch: types, statuses, priorities, impacts, tags, mineOnly...).',
1047
- inputSchema: z.object({
1048
- company_id: z.number(),
1049
- page: z.number().optional(),
1050
- limit: z.number().optional(),
1051
- search: z.string().optional(),
1052
- sort_by: z.string().optional(),
1053
- descending: z.boolean().optional(),
1054
- params: z.record(z.string(), z.any()).optional(),
1055
- }),
1056
- annotations: { title: 'List Tickets', ...READ },
1057
- },
1058
- async ({ company_id, page, limit, search, sort_by, descending, params }) => {
1059
- try {
1060
- return ok(await gateway.get_tickets(company_id, { page, limit, search, sort_by, descending, params }));
1061
- } catch (err) {
1062
- return fail(err, 'Failed to list tickets');
1063
- }
1064
- }
1065
- );
1066
-
1067
- server.registerTool(
1068
- 'get_ticket',
1069
- {
1070
- description: 'Get a single ticket by id, including status, priority, impact and assignee.',
1071
- inputSchema: z.object({ company_id: z.number(), ticket_id: z.number() }),
1072
- annotations: { title: 'Ticket Details', ...READ },
1073
- },
1074
- async ({ company_id, ticket_id }) => {
1075
- try {
1076
- return ok(await gateway.get_ticket(company_id, ticket_id));
1077
- } catch (err) {
1078
- return fail(err, 'Failed to get ticket');
1079
- }
1080
- }
1081
- );
638
+ write: true,
639
+ }, (a) => gql.create_pentest_artifact(a));
1082
640
 
1083
- server.registerTool(
1084
- 'get_requirements',
1085
- {
1086
- description: 'List security requirements/checklists for a scope (company) id, paginated. Optional: filters (RequirementsFilterInput).',
1087
- inputSchema: z.object({
1088
- scope_id: z.number(),
1089
- page: z.number().optional(),
1090
- limit: z.number().optional(),
1091
- filters: z.record(z.string(), z.any()).optional(),
1092
- }),
1093
- annotations: { title: 'List Requirements', ...READ },
1094
- },
1095
- async ({ scope_id, page, limit, filters }) => {
1096
- try {
1097
- return ok(await gateway.get_requirements(scope_id, { page, limit, filters }));
1098
- } catch (err) {
1099
- return fail(err, 'Failed to list requirements');
1100
- }
1101
- }
1102
- );
1103
-
1104
- server.registerTool(
1105
- 'get_requirement',
1106
- {
1107
- description: 'Get a single requirement/checklist by id.',
1108
- inputSchema: z.object({ company_id: z.number(), requirement_id: z.number() }),
1109
- annotations: { title: 'Requirement Details', ...READ },
1110
- },
1111
- async ({ company_id, requirement_id }) => {
1112
- try {
1113
- return ok(await gateway.get_requirement(company_id, requirement_id));
1114
- } catch (err) {
1115
- return fail(err, 'Failed to get requirement');
1116
- }
1117
- }
1118
- );
1119
-
1120
- server.registerTool(
1121
- 'get_project_requirements',
1122
- {
1123
- description: 'List the requirements/checklists attached to a specific project.',
1124
- inputSchema: z.object({ project_id: z.number() }),
1125
- annotations: { title: 'Project Requirements', ...READ },
1126
- },
1127
- async ({ project_id }) => {
1128
- try {
1129
- return ok(await gateway.get_project_requirements(project_id));
1130
- } catch (err) {
1131
- return fail(err, 'Failed to list project requirements');
1132
- }
1133
- }
1134
- );
1135
-
1136
- server.registerTool(
1137
- 'get_applications',
1138
- {
1139
- description: 'List applications for a company (id, name, url, riskScore, assetsCount). Optional: search by name.',
1140
- inputSchema: z.object({ company_id: z.number(), search: z.string().optional() }),
1141
- annotations: { title: 'List Applications', ...READ },
1142
- },
1143
- async ({ company_id, search }) => {
1144
- try {
1145
- return ok(await gateway.get_applications(company_id, search));
1146
- } catch (err) {
1147
- return fail(err, 'Failed to list applications');
1148
- }
1149
- }
1150
- );
1151
-
1152
- server.registerTool(
1153
- 'get_application',
1154
- {
1155
- description: 'Get a single application by id, including its linked assets.',
1156
- inputSchema: z.object({ company_id: z.number(), application_id: z.number() }),
1157
- annotations: { title: 'Application Details', ...READ },
1158
- },
1159
- async ({ company_id, application_id }) => {
1160
- try {
1161
- return ok(await gateway.get_application(company_id, application_id));
1162
- } catch (err) {
1163
- return fail(err, 'Failed to get application');
1164
- }
1165
- }
1166
- );
1167
-
1168
- server.registerTool(
1169
- 'get_scan_histories',
1170
- {
1171
- description: 'List scan execution histories for a company (status, integration, durations, vulnerability counts). Optional: asset_ids, page, limit, filters (ScansHistoriesFiltersInput).',
1172
- inputSchema: z.object({
1173
- company_id: z.number(),
1174
- asset_ids: z.array(z.number()).optional(),
1175
- page: z.number().optional(),
1176
- limit: z.number().optional(),
1177
- filters: z.record(z.string(), z.any()).optional(),
1178
- }),
1179
- annotations: { title: 'List Scan Histories', ...READ },
1180
- },
1181
- async ({ company_id, asset_ids, page, limit, filters }) => {
1182
- try {
1183
- return ok(await gateway.get_scan_histories(company_id, { assetIds: asset_ids, page, limit, filters }));
1184
- } catch (err) {
1185
- return fail(err, 'Failed to list scan histories');
1186
- }
1187
- }
1188
- );
1189
-
1190
- server.registerTool(
1191
- 'get_asset_scans_count',
1192
- {
1193
- description: 'Get scan coverage counts for a company (assets with scans, without scans, and which scans are considered).',
1194
- inputSchema: z.object({ company_id: z.number() }),
1195
- annotations: { title: 'Asset Scans Count', ...READ },
1196
- },
1197
- async ({ company_id }) => {
1198
- try {
1199
- return ok(await gateway.get_asset_scans_count(company_id));
1200
- } catch (err) {
1201
- return fail(err, 'Failed to get asset scans count');
1202
- }
1203
- }
1204
- );
1205
-
1206
- server.registerTool(
1207
- 'get_sbom_components',
1208
- {
1209
- description: 'List Software Bill of Materials (SBOM) / supply-chain components for a company (name, version, technology, package manager, license, issues by severity). Optional: search (SbomComponentSearchInput).',
1210
- inputSchema: z.object({
1211
- company_id: z.number(),
1212
- page: z.number().optional(),
1213
- limit: z.number().optional(),
1214
- search: z.record(z.string(), z.any()).optional(),
1215
- }),
1216
- annotations: { title: 'List SBOM Components', ...READ },
1217
- },
1218
- async ({ company_id, page, limit, search }) => {
1219
- try {
1220
- return ok(await gateway.get_sbom_components(company_id, { page, limit, search }));
1221
- } catch (err) {
1222
- return fail(err, 'Failed to list SBOM components');
1223
- }
1224
- }
1225
- );
1226
-
1227
- server.registerTool(
1228
- 'get_pentest_artifacts',
1229
- {
1230
- description: 'List AI-Pentest artifacts for a company (label, type, scheduling, latest execution). Optional: search, assignee_email, pentest_type, application_id.',
1231
- inputSchema: z.object({
1232
- company_id: z.number(),
1233
- page: z.number().optional(),
1234
- limit: z.number().optional(),
1235
- search: z.string().optional(),
1236
- assignee_email: z.string().optional(),
1237
- pentest_type: z.string().optional(),
1238
- application_id: z.number().optional(),
1239
- }),
1240
- annotations: { title: 'List Pentest Artifacts', ...READ },
1241
- },
1242
- async ({ company_id, page, limit, search, assignee_email, pentest_type, application_id }) => {
1243
- try {
1244
- return ok(await gateway.get_pentest_artifacts(company_id, {
1245
- page, limit, search, assigneeEmail: assignee_email, pentestType: pentest_type, applicationId: application_id,
1246
- }));
1247
- } catch (err) {
1248
- return fail(err, 'Failed to list pentest artifacts');
1249
- }
1250
- }
1251
- );
1252
-
1253
- server.registerTool(
1254
- 'get_pentest_artifact',
1255
- {
1256
- description: 'Get a single AI-Pentest artifact by id, including scope and its executions.',
1257
- inputSchema: z.object({ artifact_id: z.number() }),
1258
- annotations: { title: 'Pentest Artifact Details', ...READ },
1259
- },
1260
- async ({ artifact_id }) => {
1261
- try {
1262
- return ok(await gateway.get_pentest_artifact(artifact_id));
1263
- } catch (err) {
1264
- return fail(err, 'Failed to get pentest artifact');
1265
- }
1266
- }
1267
- );
1268
-
1269
- server.registerTool(
1270
- 'get_pentest_execution',
1271
- {
1272
- description: 'Get the result/status of a single AI-Pentest execution by id (status, vulnerability count, severity breakdown, retest progress).',
1273
- inputSchema: z.object({ execution_id: z.number() }),
1274
- annotations: { title: 'Pentest Execution Result', ...READ },
1275
- },
1276
- async ({ execution_id }) => {
1277
- try {
1278
- return ok(await gateway.get_pentest_execution(execution_id));
1279
- } catch (err) {
1280
- return fail(err, 'Failed to get pentest execution');
1281
- }
1282
- }
1283
- );
1284
-
1285
- server.registerTool(
1286
- 'get_threat_model_artifacts',
1287
- {
1288
- description: 'List Threat Modeling artifacts for a company (label, scope, latest version). Optional: search, assignee_email, has_version.',
1289
- inputSchema: z.object({
1290
- company_id: z.number(),
1291
- page: z.number().optional(),
1292
- limit: z.number().optional(),
1293
- search: z.string().optional(),
1294
- assignee_email: z.string().optional(),
1295
- has_version: z.boolean().optional(),
1296
- }),
1297
- annotations: { title: 'List Threat Model Artifacts', ...READ },
1298
- },
1299
- async ({ company_id, page, limit, search, assignee_email, has_version }) => {
1300
- try {
1301
- return ok(await gateway.get_threat_model_artifacts(company_id, {
1302
- page, limit, search, assigneeEmail: assignee_email, hasVersion: has_version,
1303
- }));
1304
- } catch (err) {
1305
- return fail(err, 'Failed to list threat model artifacts');
1306
- }
1307
- }
1308
- );
1309
-
1310
- server.registerTool(
1311
- 'get_threat_model_artifact',
1312
- {
1313
- description: 'Get a single Threat Modeling artifact by id, including its versions (diagrams, notes, scope).',
1314
- inputSchema: z.object({ artifact_id: z.number() }),
1315
- annotations: { title: 'Threat Model Artifact Details', ...READ },
1316
- },
1317
- async ({ artifact_id }) => {
1318
- try {
1319
- return ok(await gateway.get_threat_model_artifact(artifact_id));
1320
- } catch (err) {
1321
- return fail(err, 'Failed to get threat model artifact');
1322
- }
1323
- }
1324
- );
641
+ return server;
642
+ }
1325
643
 
1326
644
  /**
1327
645
  * START
@@ -1335,7 +653,14 @@ if (PORT) {
1335
653
  res.writeHead(405).end();
1336
654
  return;
1337
655
  }
656
+ // Stateless HTTP: a fresh server + transport per request (SDK pattern). Reusing one
657
+ // McpServer across concurrent transports leaks state between requests.
658
+ const server = buildServer();
1338
659
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
660
+ res.on('close', () => {
661
+ transport.close();
662
+ server.close();
663
+ });
1339
664
  await server.connect(transport);
1340
665
  await transport.handleRequest(req, res);
1341
666
  });
@@ -1344,7 +669,8 @@ if (PORT) {
1344
669
  console.error(`Conviso MCP Server running on HTTP port ${PORT}`);
1345
670
  });
1346
671
  } else {
672
+ const server = buildServer();
1347
673
  const transport = new StdioServerTransport();
1348
674
  await server.connect(transport);
1349
675
  console.error('Conviso MCP Server running on stdio');
1350
- }
676
+ }