@convisoappsec/mcp 0.3.1 → 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.3.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)}`;
@@ -39,7 +36,16 @@ function sanitizeError(err, message = 'Request failed') {
39
36
  status,
40
37
  });
41
38
 
42
- return { error: message, status, error_id };
39
+ const result = { error: message, status, error_id };
40
+ // GraphQL errors describe the caller's own request (e.g. a missing required field) — pass
41
+ // them through so the model can fix the input on the next attempt.
42
+ if (Array.isArray(err?.graphqlErrors) && err.graphqlErrors.length) {
43
+ result.details = err.graphqlErrors;
44
+ }
45
+ if (err?.authHint) {
46
+ result.hint = err.authHint;
47
+ }
48
+ return result;
43
49
  }
44
50
 
45
51
  function ok(data) {
@@ -53,284 +59,243 @@ function ok(data) {
53
59
  };
54
60
  }
55
61
 
56
- function fail(err, msg) {
57
- return ok(sanitizeError(err, msg));
58
- }
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
+
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
+ });
76
+
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
+ }
59
101
 
60
- server.registerTool(
61
- 'get_companies',
62
- {
63
- description: 'Return a paginated list of companies accessible with the provided API key. Use `search` to filter by company name.',
64
- inputSchema: z.object({
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({
65
110
  page: z.number().optional(),
66
111
  limit: z.number().optional(),
67
112
  search: z.string().optional(),
113
+ label_eq: z.string().optional(),
68
114
  }),
69
- annotations: {
70
- title: 'List Companies',
71
- readOnlyHint: true,
72
- destructiveHint: false,
73
- idempotentHint: true,
74
- openWorldHint: true,
75
- },
76
- },
77
- async ({ page = 1, limit = 10, search = '' }) => {
78
- try {
79
- return ok(await gateway.get_companies(page, limit, search));
80
- } catch (err) {
81
- return fail(err, 'Failed to list companies');
82
- }
83
- }
84
- );
85
-
86
- server.registerTool(
87
- 'get_company_info',
88
- {
89
- description: 'Retrieve detailed information about a specific company, including plan, integrations, and branding metadata.',
90
- inputSchema: z.object({ company_id: z.number() }),
91
- annotations: {
92
- title: 'Company Details',
93
- readOnlyHint: true,
94
- destructiveHint: false,
95
- idempotentHint: true,
96
- openWorldHint: true,
97
- },
98
- },
99
- async ({ company_id }) => {
100
- try {
101
- return ok(await gateway.get_company_by_id(company_id));
102
- } catch (err) {
103
- return fail(err, 'Failed to get company info');
104
- }
105
- }
106
- );
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));
107
123
 
108
- server.registerTool(
109
- 'get_issue',
110
- {
111
- 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.',
112
- inputSchema: z.object({
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({
113
128
  id: z.number(),
114
129
  return_vulnerable_data: z.boolean().optional(),
115
130
  }),
116
- annotations: {
117
- title: 'Issue Details',
118
- readOnlyHint: true,
119
- destructiveHint: false,
120
- idempotentHint: true,
121
- openWorldHint: true,
122
- },
123
- },
124
- async ({ id, return_vulnerable_data }) => {
125
- try {
126
- return ok(await gateway.get_issue_by_id(id, return_vulnerable_data));
127
- } catch (err) {
128
- return fail(err, 'Failed to get issue details');
129
- }
130
- }
131
- );
131
+ }, ({ id, return_vulnerable_data }) => gql.get_issue_by_id(id, return_vulnerable_data));
132
132
 
133
- server.registerTool(
134
- 'get_issues',
135
- {
136
- description: 'List vulnerabilities for a company or project.',
137
- 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({
138
137
  company_id: z.number(),
139
138
  page: z.number().optional(),
140
139
  limit: z.number().optional(),
141
140
  project_id: z.number().optional(),
141
+ asset_id: z.number().optional(),
142
+ search: z.string().optional(),
143
+ severities: z.array(z.string()).optional(),
144
+ statuses: z.array(z.string()).optional(),
145
+ sla_states: z.array(z.string()).optional(),
146
+ created_after: z.string().optional(),
147
+ created_before: z.string().optional(),
148
+ assignee_emails: z.array(z.string()).optional(),
149
+ sort_by: z.string().optional(),
150
+ order: z.string().optional(),
151
+ extra_filters: z.record(z.string(), z.any()).optional(),
142
152
  }),
143
- annotations: {
144
- title: 'List Issues',
145
- readOnlyHint: true,
146
- destructiveHint: false,
147
- idempotentHint: true,
148
- openWorldHint: true,
149
- },
150
- },
151
- async ({ company_id, page = 1, limit = 10, project_id }) => {
152
- try {
153
- return ok(await gateway.get_issues(company_id, '', page, limit, project_id));
154
- } catch (err) {
155
- return fail(err, 'Failed to list issues');
156
- }
157
- }
158
- );
159
-
160
- server.registerTool(
161
- 'get_top_vulnerabilities',
162
- {
163
- description: 'Return a summary of vulnerability counts grouped by severity for a given company (risk overview).',
164
- inputSchema: z.object({ company_id: z.number() }),
165
- annotations: {
166
- title: 'Top Vulnerabilities',
167
- readOnlyHint: true,
168
- destructiveHint: false,
169
- idempotentHint: true,
170
- openWorldHint: true,
171
- },
172
- },
173
- async ({ company_id }) => {
174
- try {
175
- return ok(await gateway.get_top_vulnerabilities(company_id));
176
- } catch (err) {
177
- return fail(err, 'Failed to get top vulnerabilities');
178
- }
179
- }
180
- );
153
+ }, ({
154
+ company_id, page = 1, limit = 10, project_id, asset_id, search = '',
155
+ severities, statuses, sla_states, created_after, created_before,
156
+ assignee_emails, sort_by, order = 'DESC', extra_filters,
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
+ }));
181
173
 
182
- server.registerTool(
183
- 'get_projects',
184
- {
185
- description: 'Return a paginated list of active security projects for a company. Defaults to 25 results per page to conserve tokens.',
186
- inputSchema: z.object({
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({
178
+ company_id: z.number(),
179
+ severities: z.array(z.string()).optional(),
180
+ statuses: z.array(z.string()).optional(),
181
+ asset_ids: z.array(z.number()).optional(),
182
+ asset_tags: z.array(z.string()).optional(),
183
+ created_after: z.string().optional(),
184
+ created_before: z.string().optional(),
185
+ }),
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({
187
200
  company_id: z.number(),
188
201
  page: z.number().optional(),
189
202
  limit: z.number().optional(),
190
203
  search: z.string().optional(),
204
+ statuses: z.array(z.string()).optional(),
205
+ project_types: z.array(z.string()).optional(),
206
+ created_after: z.string().optional(),
207
+ created_before: z.string().optional(),
208
+ tags: z.array(z.string()).optional(),
209
+ analyst_emails: z.array(z.string()).optional(),
210
+ sort_by: z.string().optional(),
211
+ descending: z.boolean().optional(),
191
212
  }),
192
- annotations: {
193
- title: 'List Projects',
194
- readOnlyHint: true,
195
- destructiveHint: false,
196
- idempotentHint: true,
197
- openWorldHint: true,
198
- },
199
- },
200
- async ({ company_id, page = 1, limit = 25, search = '' }) => {
201
- try {
202
- return ok(await gateway.get_projects(company_id, page, limit, search));
203
- } catch (err) {
204
- return fail(err, 'Failed to list projects');
205
- }
206
- }
207
- );
208
-
209
- server.registerTool(
210
- 'get_project',
211
- {
212
- description: 'Retrieve detailed metadata for a specific project by its ID.',
213
- inputSchema: z.object({ project_id: z.number() }),
214
- annotations: {
215
- title: 'Project Details',
216
- readOnlyHint: true,
217
- destructiveHint: false,
218
- idempotentHint: true,
219
- openWorldHint: true,
220
- },
221
- },
222
- async ({ project_id }) => {
223
- try {
224
- return ok(await gateway.get_project_by_id(project_id));
225
- } catch (err) {
226
- return fail(err, 'Failed to get project');
227
- }
228
- }
229
- );
230
-
231
- server.registerTool(
232
- 'get_asset',
233
- {
234
- description: 'Fetch information about a specific asset by its ID.',
235
- inputSchema: z.object({ asset_id: z.number() }),
236
- annotations: {
237
- title: 'Asset Details',
238
- readOnlyHint: true,
239
- destructiveHint: false,
240
- idempotentHint: true,
241
- openWorldHint: true,
242
- },
243
- },
244
- async ({ asset_id }) => {
245
- try {
246
- return ok(await gateway.get_asset_by_id(asset_id));
247
- } catch (err) {
248
- return fail(err, 'Failed to get asset');
249
- }
250
- }
251
- );
213
+ }, ({
214
+ company_id, page = 1, limit = 25, search = '', statuses, project_types,
215
+ created_after, created_before, tags, analyst_emails, sort_by = 'createdAt',
216
+ descending = true,
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));
252
233
 
253
- server.registerTool(
254
- 'get_assets',
255
- {
256
- description: 'Return a paginated list of assets for a company. Defaults to 25 results per page to reduce token usage.',
257
- inputSchema: z.object({
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({
258
244
  company_id: z.number(),
259
245
  page: z.number().optional(),
260
246
  limit: z.number().optional(),
247
+ name: z.string().optional(),
248
+ search: z.string().optional(),
249
+ tags: z.array(z.string()).optional(),
250
+ technology: z.array(z.string()).optional(),
251
+ business_impact: z.array(z.string()).optional(),
252
+ exploitability: z.array(z.string()).optional(),
253
+ asset_type: z.string().optional(),
254
+ environment_compromised: z.boolean().optional(),
255
+ covered_by_scan: z.boolean().optional(),
256
+ sort_by: z.string().optional(),
257
+ order: z.string().optional(),
258
+ extra_filters: z.record(z.string(), z.any()).optional(),
261
259
  }),
262
- annotations: {
263
- title: 'List Assets',
264
- readOnlyHint: true,
265
- destructiveHint: false,
266
- idempotentHint: true,
267
- openWorldHint: true,
268
- },
269
- },
270
- async ({ company_id, page = 1, limit = 25 }) => {
271
- try {
272
- return ok(await gateway.get_assets(company_id, page, limit));
273
- } catch (err) {
274
- return fail(err, 'Failed to list assets');
275
- }
276
- }
277
- );
260
+ }, ({
261
+ company_id, page = 1, limit = 25, name, search, tags, technology,
262
+ business_impact, exploitability, asset_type, environment_compromised,
263
+ covered_by_scan, sort_by, order, extra_filters,
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
278
 
279
- server.registerTool(
280
- 'create_project_url',
281
- {
282
- description: 'Return a direct URL to open a project in the Conviso Platform for quick navigation.',
283
- inputSchema: z.object({
284
- company_id: z.number(),
285
- project_id: z.number(),
286
- }),
287
- annotations: {
288
- title: 'Project URL Generator',
289
- readOnlyHint: true,
290
- destructiveHint: false,
291
- idempotentHint: true,
292
- openWorldHint: true,
293
- },
294
- },
295
- async ({ company_id, project_id }) => {
296
- try {
297
- return ok(await gateway.create_project_url(company_id, project_id));
298
- } catch (err) {
299
- return fail(err, 'Failed to create project URL');
300
- }
301
- }
302
- );
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}`);
303
286
 
304
- server.registerTool(
305
- 'create_issue_url',
306
- {
307
- description: 'Return a direct URL to open a specific issue in the Conviso Platform for triage or review.',
308
- inputSchema: z.object({
309
- company_id: z.number(),
310
- issue_id: z.number(),
311
- }),
312
- annotations: {
313
- title: 'Issue URL Generator',
314
- readOnlyHint: true,
315
- destructiveHint: false,
316
- idempotentHint: true,
317
- openWorldHint: true,
318
- },
319
- },
320
- async ({ company_id, issue_id }) => {
321
- try {
322
- return ok(await gateway.create_issue_url(company_id, issue_id));
323
- } catch (err) {
324
- return fail(err, 'Failed to create issue URL');
325
- }
326
- }
327
- );
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}`);
328
294
 
329
- server.registerTool(
330
- 'get_mttr_over_time',
331
- {
332
- description: 'Get Mean Time To Resolution (MTTR) aggregated over a date range. Supports filtering by severities, statuses, and assets.',
333
- inputSchema: z.object({
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({
334
299
  company_id: z.number(),
335
300
  start_date: z.string(),
336
301
  end_date: z.string(),
@@ -339,77 +304,330 @@ server.registerTool(
339
304
  asset_ids: z.array(z.number()).optional(),
340
305
  asset_tags: z.array(z.string()).optional(),
341
306
  }),
342
- annotations: {
343
- title: 'MTTR Over Time',
344
- readOnlyHint: true,
345
- destructiveHint: false,
346
- idempotentHint: true,
347
- openWorldHint: true,
348
- },
349
- },
350
- async (args) => {
351
- try {
352
- return ok(await gateway.get_mttr_over_time(
353
- args.company_id,
354
- args.start_date,
355
- args.end_date,
356
- args.severities,
357
- args.statuses,
358
- args.asset_ids,
359
- args.asset_tags
360
- ));
361
- } catch (err) {
362
- return fail(err, 'Failed to get MTTR metrics');
363
- }
364
- }
365
- );
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
+ */
366
328
 
367
- server.registerTool(
368
- 'get_overall_risk_score_history',
369
- {
370
- description: 'Retrieve historical overall risk scores for a company, useful for trend analysis and reporting.',
371
- 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({
372
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(),
373
340
  }),
374
- annotations: {
375
- title: 'Risk Score History',
376
- readOnlyHint: true,
377
- destructiveHint: false,
378
- idempotentHint: true,
379
- openWorldHint: true,
380
- },
381
- },
382
- async ({ company_id }) => {
383
- try {
384
- return ok(await gateway.get_overall_risk_score_history(company_id));
385
- } catch (err) {
386
- return fail(err, 'Failed to get risk score history');
387
- }
388
- }
389
- );
390
-
391
- server.registerTool(
392
- 'get_today_date',
393
- {
394
- description: 'Utility tool returning the current date.',
395
- inputSchema: z.object({}),
396
- annotations: {
397
- title: 'Get Today Date',
398
- readOnlyHint: true,
399
- destructiveHint: false,
400
- idempotentHint: true,
401
- openWorldHint: false,
402
- },
403
- },
404
- async () => {
405
- try {
406
- const d = new Date();
407
- return ok({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() });
408
- } catch (err) {
409
- return fail(err, 'Failed to get current date');
410
- }
411
- }
412
- );
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 }));
414
+
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(),
422
+ search: 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(),
450
+ limit: z.number().optional(),
451
+ search: z.string().optional(),
452
+ assignee_email: z.string().optional(),
453
+ has_version: z.boolean().optional(),
454
+ }),
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(),
477
+ }),
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({
492
+ name: z.string(),
493
+ variables: z.record(z.string(), z.any()).optional(),
494
+ return_fields: z.string().optional(),
495
+ }),
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({
509
+ issue_id: z.number(),
510
+ status: z.string(),
511
+ reason: z.string().optional(),
512
+ extra: z.record(z.string(), z.any()).optional(),
513
+ }),
514
+ write: true,
515
+ }, (a) => gql.change_issue_status(a));
516
+
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({
521
+ asset_id: z.number(),
522
+ title: z.string(),
523
+ description: z.string(),
524
+ solution: z.string(),
525
+ severity: z.string(),
526
+ code_snippet: z.string(),
527
+ file_name: z.string(),
528
+ first_line: z.number(),
529
+ vulnerable_line: z.number(),
530
+ project_id: z.number().optional(),
531
+ impact_level: z.string().optional(),
532
+ probability_level: z.string().optional(),
533
+ status: z.string().optional(),
534
+ category: z.string().optional(),
535
+ reference: z.string().optional(),
536
+ summary: z.string().optional(),
537
+ impact_description: z.string().optional(),
538
+ steps_to_reproduce: z.string().optional(),
539
+ compromised_environment: z.boolean().optional(),
540
+ source: z.string().optional(),
541
+ sink: z.string().optional(),
542
+ patterns: z.array(z.string()).optional(),
543
+ extra: z.record(z.string(), z.any()).optional(),
544
+ }),
545
+ write: true,
546
+ }, (a) => gql.create_source_code_vulnerability(a));
547
+
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({
552
+ company_id: z.number(),
553
+ type_id: z.number(),
554
+ label: z.string(),
555
+ goal: z.string(),
556
+ scope: z.string(),
557
+ start_date: z.string(),
558
+ end_date: z.string().optional(),
559
+ extra: z.record(z.string(), z.any()).optional(),
560
+ }),
561
+ write: true,
562
+ }, (a) => gql.create_project(a));
563
+
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({
568
+ company_id: z.number(),
569
+ name: z.string(),
570
+ asset_type: z.string().optional(),
571
+ url: z.string().optional(),
572
+ description: z.string().optional(),
573
+ business_impact: z.string().optional(),
574
+ exploitability: z.string().optional(),
575
+ tags: z.array(z.string()).optional(),
576
+ extra: z.record(z.string(), z.any()).optional(),
577
+ }),
578
+ write: true,
579
+ }, (a) => gql.create_asset(a));
580
+
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({
585
+ company_id: z.number(),
586
+ type: z.string(),
587
+ title: z.string(),
588
+ description: z.string(),
589
+ priority: z.string().optional(),
590
+ impact: z.string().optional(),
591
+ extra: z.record(z.string(), z.any()).optional(),
592
+ }),
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({
614
+ company_id: z.number(),
615
+ application_id: z.number(),
616
+ label: z.string(),
617
+ pentest_type: z.string(),
618
+ description: z.string().optional(),
619
+ scope_text: z.string().optional(),
620
+ assignee_email: z.string().optional(),
621
+ domains: z.array(z.string()).optional(),
622
+ in_scope: z.array(z.string()).optional(),
623
+ out_scope: z.array(z.string()).optional(),
624
+ extra: z.record(z.string(), z.any()).optional(),
625
+ }),
626
+ write: true,
627
+ }, (a) => gql.create_pentest_artifact(a));
628
+
629
+ return server;
630
+ }
413
631
 
414
632
  /**
415
633
  * START
@@ -423,7 +641,14 @@ if (PORT) {
423
641
  res.writeHead(405).end();
424
642
  return;
425
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();
426
647
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
648
+ res.on('close', () => {
649
+ transport.close();
650
+ server.close();
651
+ });
427
652
  await server.connect(transport);
428
653
  await transport.handleRequest(req, res);
429
654
  });
@@ -432,7 +657,8 @@ if (PORT) {
432
657
  console.error(`Conviso MCP Server running on HTTP port ${PORT}`);
433
658
  });
434
659
  } else {
660
+ const server = buildServer();
435
661
  const transport = new StdioServerTransport();
436
662
  await server.connect(transport);
437
663
  console.error('Conviso MCP Server running on stdio');
438
- }
664
+ }