@convisoappsec/mcp 0.5.0 → 0.6.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.
@@ -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,37 @@ 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_asset', {
235
+ title: 'Asset Details',
236
+ desc: 'Get an asset by ID.',
237
+ schema: z.object({ asset_id: z.number() }),
238
+ }, ({ asset_id }) => gql.get_asset_by_id(asset_id));
239
+
240
+ tool('get_assets', {
241
+ title: 'List Assets',
242
+ 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.',
243
+ schema: z.object({
526
244
  company_id: z.number(),
527
245
  page: z.number().optional(),
528
246
  limit: z.number().optional(),
@@ -539,95 +257,45 @@ plus metadata (totalCount, totalPages, currentPage) for pagination.`,
539
257
  order: z.string().optional(),
540
258
  extra_filters: z.record(z.string(), z.any()).optional(),
541
259
  }),
542
- annotations: {
543
- title: 'List Assets',
544
- readOnlyHint: true,
545
- destructiveHint: false,
546
- idempotentHint: true,
547
- openWorldHint: true,
548
- },
549
- },
550
- async ({
260
+ }, ({
551
261
  company_id, page = 1, limit = 25, name, search, tags, technology,
552
262
  business_impact, exploitability, asset_type, environment_compromised,
553
263
  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({
264
+ }) => gql.get_assets_by_company(company_id, page, limit, {
265
+ name,
266
+ search,
267
+ tags,
268
+ technology,
269
+ businessImpact: business_impact,
270
+ exploitability,
271
+ assetType: asset_type,
272
+ environmentCompromised: environment_compromised,
273
+ coveredByScan: covered_by_scan,
274
+ sortBy: sort_by,
275
+ order,
276
+ extraFilters: extra_filters,
277
+ }));
278
+
279
+ tool('create_project_url', {
280
+ title: 'Project URL',
281
+ desc: 'Build the direct Conviso Platform URL for a project.',
282
+ schema: z.object({ company_id: z.number(), project_id: z.number() }),
283
+ local: true,
284
+ }, ({ company_id, project_id }) =>
285
+ `${BASE_URL}/spa/company/${company_id}/projects/${project_id}`);
286
+
287
+ tool('create_issue_url', {
288
+ title: 'Issue URL',
289
+ desc: 'Build the direct Conviso Platform URL for an issue.',
290
+ schema: z.object({ company_id: z.number(), issue_id: z.number() }),
291
+ local: true,
292
+ }, ({ company_id, issue_id }) =>
293
+ `${BASE_URL}/spa/company/${company_id}/vulnerabilities?title=&search=${issue_id}`);
294
+
295
+ tool('get_mttr_over_time', {
296
+ title: 'MTTR Over Time',
297
+ desc: 'Mean Time To Resolution over a date range, broken down by severity. Optional filters: severities, statuses, asset_ids, asset_tags.',
298
+ schema: z.object({
631
299
  company_id: z.number(),
632
300
  start_date: z.string(),
633
301
  end_date: z.string(),
@@ -636,208 +304,220 @@ server.registerTool(
636
304
  asset_ids: z.array(z.number()).optional(),
637
305
  asset_tags: z.array(z.string()).optional(),
638
306
  }),
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
- );
307
+ }, (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));
308
+
309
+ tool('get_overall_risk_score_history', {
310
+ title: 'Risk Score History',
311
+ desc: 'Historical overall risk score for a company (current value + difference from last period).',
312
+ schema: z.object({ company_id: z.number() }),
313
+ }, ({ company_id }) => gql.get_overall_risk_score_history(company_id));
314
+
315
+ tool('get_today_date', {
316
+ title: 'Get Today Date',
317
+ desc: 'Current day/month/year — use to compute relative date ranges before filtering by dates.',
318
+ schema: z.object({}),
319
+ local: true,
320
+ }, () => {
321
+ const d = new Date();
322
+ return { day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() };
323
+ });
324
+
325
+ /**
326
+ * READS tickets, requirements, applications, scans, supply chain, AI-pentest, threat modeling
327
+ */
663
328
 
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({
329
+ tool('get_tickets', {
330
+ title: 'List Tickets',
331
+ desc: 'List a company\'s tickets (paginated). Optional: search, sort_by + descending, params (raw TicketSearch keys: types, statuses, priorities, impacts, tags, mineOnly...).',
332
+ schema: z.object({
669
333
  company_id: z.number(),
334
+ page: z.number().optional(),
335
+ limit: z.number().optional(),
336
+ search: z.string().optional(),
337
+ sort_by: z.string().optional(),
338
+ descending: z.boolean().optional(),
339
+ params: z.record(z.string(), z.any()).optional(),
670
340
  }),
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).
341
+ }, ({ company_id, page, limit, search, sort_by, descending, params }) =>
342
+ gql.get_tickets(company_id, { page, limit, search, sort_by, descending, params }));
343
+
344
+ tool('get_ticket', {
345
+ title: 'Ticket Details',
346
+ desc: 'Get a ticket by ID (status, priority, impact, assignee).',
347
+ schema: z.object({ company_id: z.number(), ticket_id: z.number() }),
348
+ }, ({ company_id, ticket_id }) => gql.get_ticket(company_id, ticket_id));
349
+
350
+ tool('get_requirements', {
351
+ title: 'List Requirements',
352
+ desc: 'List security requirements/checklists for a scope (company) id, paginated. Optional: filters (raw RequirementsFilterInput).',
353
+ schema: z.object({
354
+ scope_id: z.number(),
355
+ page: z.number().optional(),
356
+ limit: z.number().optional(),
357
+ filters: z.record(z.string(), z.any()).optional(),
358
+ }),
359
+ }, ({ scope_id, page, limit, filters }) => gql.get_requirements(scope_id, { page, limit, filters }));
360
+
361
+ tool('get_requirement', {
362
+ title: 'Requirement Details',
363
+ desc: 'Get a requirement/checklist by ID.',
364
+ schema: z.object({ company_id: z.number(), requirement_id: z.number() }),
365
+ }, ({ company_id, requirement_id }) => gql.get_requirement(company_id, requirement_id));
366
+
367
+ tool('get_project_requirements', {
368
+ title: 'Project Requirements',
369
+ desc: 'List the requirements/checklists attached to a project.',
370
+ schema: z.object({ project_id: z.number() }),
371
+ }, ({ project_id }) => gql.get_project_requirements(project_id));
372
+
373
+ tool('get_applications', {
374
+ title: 'List Applications',
375
+ desc: 'List a company\'s applications (name, url, riskScore, assetsCount). Optional: search by name.',
376
+ schema: z.object({ company_id: z.number(), search: z.string().optional() }),
377
+ }, ({ company_id, search }) => gql.get_applications(company_id, search));
378
+
379
+ tool('get_application', {
380
+ title: 'Application Details',
381
+ desc: 'Get an application by ID, including its linked assets.',
382
+ schema: z.object({ company_id: z.number(), application_id: z.number() }),
383
+ }, ({ company_id, application_id }) => gql.get_application(company_id, application_id));
384
+
385
+ tool('get_scan_histories', {
386
+ title: 'List Scan Histories',
387
+ desc: 'List scan executions for a company (status, integration, duration, vulnerability counts). Optional: asset_ids, filters (raw ScansHistoriesFiltersInput).',
388
+ schema: z.object({
389
+ company_id: z.number(),
390
+ asset_ids: z.array(z.number()).optional(),
391
+ page: z.number().optional(),
392
+ limit: z.number().optional(),
393
+ filters: z.record(z.string(), z.any()).optional(),
394
+ }),
395
+ }, ({ company_id, asset_ids, page, limit, filters }) =>
396
+ gql.get_scan_histories(company_id, { assetIds: asset_ids, page, limit, filters }));
397
+
398
+ tool('get_asset_scans_count', {
399
+ title: 'Asset Scans Count',
400
+ desc: 'Scan coverage for a company: assets with/without scans and which scan types count.',
401
+ schema: z.object({ company_id: z.number() }),
402
+ }, ({ company_id }) => gql.get_asset_scans_count(company_id));
403
+
404
+ tool('get_sbom_components', {
405
+ title: 'List SBOM Components',
406
+ desc: 'List SBOM / supply-chain components (name, version, technology, package manager, license, issues by severity). Optional: search (raw SbomComponentSearchInput).',
407
+ schema: z.object({
408
+ company_id: z.number(),
409
+ page: z.number().optional(),
410
+ limit: z.number().optional(),
411
+ search: z.record(z.string(), z.any()).optional(),
412
+ }),
413
+ }, ({ company_id, page, limit, search }) => gql.get_sbom_components(company_id, { page, limit, search }));
720
414
 
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({
415
+ tool('get_pentest_artifacts', {
416
+ title: 'List Pentest Artifacts',
417
+ desc: 'List AI-Pentest artifacts (label, type, scheduling, latest execution). Optional: search, assignee_email, pentest_type, application_id.',
418
+ schema: z.object({
419
+ company_id: z.number(),
420
+ page: z.number().optional(),
421
+ limit: z.number().optional(),
726
422
  search: z.string().optional(),
727
- category: z.string().optional(),
423
+ assignee_email: z.string().optional(),
424
+ pentest_type: z.string().optional(),
425
+ application_id: z.number().optional(),
426
+ }),
427
+ }, ({ company_id, page, limit, search, assignee_email, pentest_type, application_id }) =>
428
+ gql.get_pentest_artifacts(company_id, {
429
+ page, limit, search, assigneeEmail: assignee_email, pentestType: pentest_type, applicationId: application_id,
430
+ }));
431
+
432
+ tool('get_pentest_artifact', {
433
+ title: 'Pentest Artifact Details',
434
+ desc: 'Get an AI-Pentest artifact by ID, including scope and executions.',
435
+ schema: z.object({ artifact_id: z.number() }),
436
+ }, ({ artifact_id }) => gql.get_pentest_artifact(artifact_id));
437
+
438
+ tool('get_pentest_execution', {
439
+ title: 'Pentest Execution Result',
440
+ desc: 'Get an AI-Pentest execution by ID: status, vulnerability count, severity breakdown, retest progress.',
441
+ schema: z.object({ execution_id: z.number() }),
442
+ }, ({ execution_id }) => gql.get_pentest_execution(execution_id));
443
+
444
+ tool('get_threat_model_artifacts', {
445
+ title: 'List Threat Model Artifacts',
446
+ desc: 'List Threat Modeling artifacts (label, scope, latest version). Optional: search, assignee_email, has_version.',
447
+ schema: z.object({
448
+ company_id: z.number(),
449
+ page: z.number().optional(),
728
450
  limit: z.number().optional(),
451
+ search: z.string().optional(),
452
+ assignee_email: z.string().optional(),
453
+ has_version: z.boolean().optional(),
729
454
  }),
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(),
455
+ }, ({ company_id, page, limit, search, assignee_email, has_version }) =>
456
+ gql.get_threat_model_artifacts(company_id, {
457
+ page, limit, search, assigneeEmail: assignee_email, hasVersion: has_version,
458
+ }));
459
+
460
+ tool('get_threat_model_artifact', {
461
+ title: 'Threat Model Artifact Details',
462
+ desc: 'Get a Threat Modeling artifact by ID, including its versions (diagrams, notes, scope).',
463
+ schema: z.object({ artifact_id: z.number() }),
464
+ }, ({ artifact_id }) => gql.get_threat_model_artifact(artifact_id));
465
+
466
+ /**
467
+ * MUTATIONS — engine (discover -> describe -> execute over the allowlist)
468
+ */
469
+
470
+ tool('list_mutations', {
471
+ title: 'List Mutations',
472
+ 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).',
473
+ schema: z.object({
474
+ search: z.string().optional(),
475
+ category: z.string().optional(),
476
+ limit: z.number().optional(),
753
477
  }),
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({
478
+ local: true,
479
+ }, ({ search, category, limit }) => listMutations({ search, category, limit }));
480
+
481
+ tool('describe_mutation', {
482
+ title: 'Describe Mutation',
483
+ 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.',
484
+ schema: z.object({ name: z.string() }),
485
+ local: true,
486
+ }, ({ name }) => describeMutation(name));
487
+
488
+ tool('execute_mutation', {
489
+ title: 'Execute Mutation',
490
+ 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.',
491
+ schema: z.object({
784
492
  name: z.string(),
785
493
  variables: z.record(z.string(), z.any()).optional(),
786
494
  return_fields: z.string().optional(),
787
495
  }),
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({
496
+ write: true,
497
+ destructive: true,
498
+ }, ({ name, variables = {}, return_fields = null }) =>
499
+ gql.executeMutation(name, variables, return_fields));
500
+
501
+ /**
502
+ * MUTATIONS — typed shortcuts for the most common writes
503
+ */
504
+
505
+ tool('change_issue_status', {
506
+ title: 'Change Issue Status',
507
+ desc: `Change an issue's status. status: one of ${ISSUE_STATUSES}. Optional reason; extra = advanced ChangeIssueStatusInput fields (e.g. riskAcceptedUntil).`,
508
+ schema: z.object({
814
509
  issue_id: z.number(),
815
510
  status: z.string(),
816
511
  reason: z.string().optional(),
817
512
  extra: z.record(z.string(), z.any()).optional(),
818
513
  }),
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
- );
514
+ write: true,
515
+ }, (a) => gql.change_issue_status(a));
835
516
 
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({
517
+ tool('create_source_code_vulnerability', {
518
+ title: 'Create Source Code Vulnerability',
519
+ 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.`,
520
+ schema: z.object({
841
521
  asset_id: z.number(),
842
522
  title: z.string(),
843
523
  description: z.string(),
@@ -862,28 +542,13 @@ server.registerTool(
862
542
  patterns: z.array(z.string()).optional(),
863
543
  extra: z.record(z.string(), z.any()).optional(),
864
544
  }),
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
- );
545
+ write: true,
546
+ }, (a) => gql.create_source_code_vulnerability(a));
881
547
 
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({
548
+ tool('create_project', {
549
+ title: 'Create Project',
550
+ desc: 'Create a project. Required: company_id, type_id (project type id), label, goal, scope, start_date (YYYY-MM-DD). Optional: end_date; extra = advanced CreateProjectInput fields (assetsIds, tags, allocatedPortalUserEmails...).',
551
+ schema: z.object({
887
552
  company_id: z.number(),
888
553
  type_id: z.number(),
889
554
  label: z.string(),
@@ -893,28 +558,13 @@ server.registerTool(
893
558
  end_date: z.string().optional(),
894
559
  extra: z.record(z.string(), z.any()).optional(),
895
560
  }),
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
- );
561
+ write: true,
562
+ }, (a) => gql.create_project(a));
912
563
 
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({
564
+ tool('create_asset', {
565
+ title: 'Create Asset',
566
+ 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.',
567
+ schema: z.object({
918
568
  company_id: z.number(),
919
569
  name: z.string(),
920
570
  asset_type: z.string().optional(),
@@ -925,28 +575,13 @@ server.registerTool(
925
575
  tags: z.array(z.string()).optional(),
926
576
  extra: z.record(z.string(), z.any()).optional(),
927
577
  }),
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
- );
578
+ write: true,
579
+ }, (a) => gql.create_asset(a));
944
580
 
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({
581
+ tool('create_ticket', {
582
+ title: 'Create Ticket',
583
+ 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.',
584
+ schema: z.object({
950
585
  company_id: z.number(),
951
586
  type: z.string(),
952
587
  title: z.string(),
@@ -955,64 +590,27 @@ server.registerTool(
955
590
  impact: z.string().optional(),
956
591
  extra: z.record(z.string(), z.any()).optional(),
957
592
  }),
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({
593
+ write: true,
594
+ }, (a) => gql.create_ticket(a));
595
+
596
+ tool('run_dast', {
597
+ title: 'Run DAST',
598
+ desc: 'Start a Conviso DAST scan on an asset (startConvisoDast). Required: asset_id.',
599
+ schema: z.object({ asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
600
+ write: true,
601
+ }, (a) => gql.run_dast(a));
602
+
603
+ tool('trigger_pentest', {
604
+ title: 'Trigger AI-Pentest',
605
+ desc: 'Trigger an AI-Pentest execution from an existing pentest artifact (createPentestExecution). Required: artifact_id.',
606
+ schema: z.object({ artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
607
+ write: true,
608
+ }, (a) => gql.trigger_pentest(a));
609
+
610
+ tool('create_pentest_artifact', {
611
+ title: 'Create Pentest Artifact',
612
+ 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).',
613
+ schema: z.object({
1016
614
  company_id: z.number(),
1017
615
  application_id: z.number(),
1018
616
  label: z.string(),
@@ -1025,303 +623,11 @@ server.registerTool(
1025
623
  out_scope: z.array(z.string()).optional(),
1026
624
  extra: z.record(z.string(), z.any()).optional(),
1027
625
  }),
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
- );
626
+ write: true,
627
+ }, (a) => gql.create_pentest_artifact(a));
1082
628
 
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
- );
629
+ return server;
630
+ }
1325
631
 
1326
632
  /**
1327
633
  * START
@@ -1335,7 +641,14 @@ if (PORT) {
1335
641
  res.writeHead(405).end();
1336
642
  return;
1337
643
  }
644
+ // Stateless HTTP: a fresh server + transport per request (SDK pattern). Reusing one
645
+ // McpServer across concurrent transports leaks state between requests.
646
+ const server = buildServer();
1338
647
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
648
+ res.on('close', () => {
649
+ transport.close();
650
+ server.close();
651
+ });
1339
652
  await server.connect(transport);
1340
653
  await transport.handleRequest(req, res);
1341
654
  });
@@ -1344,7 +657,8 @@ if (PORT) {
1344
657
  console.error(`Conviso MCP Server running on HTTP port ${PORT}`);
1345
658
  });
1346
659
  } else {
660
+ const server = buildServer();
1347
661
  const transport = new StdioServerTransport();
1348
662
  await server.connect(transport);
1349
663
  console.error('Conviso MCP Server running on stdio');
1350
- }
664
+ }