@convisoappsec/mcp 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,12 +25,13 @@ The server exposes the following tools to the LLM (see `node/manifest.json` for
25
25
  | **Metrics** | `get_mttr_over_time` | Get Mean Time To Resolution (MTTR) metrics over time for a company. Returns resolution times by severity level. |
26
26
  | **Metrics** | `get_overall_risk_score_history` | Get overall risk score history for a company, including current score and difference from last period. |
27
27
  | **Tickets** | `get_tickets` / `get_ticket` | List or fetch support/bug tickets. |
28
- | **Requirements** | `get_requirements` / `get_requirement` / `get_project_requirements` | Browse security requirements/checklists. |
28
+ | **Requirements** | `get_requirements` / `get_requirement` / `get_project_requirements` / `get_project_requirement_activities` | Browse requirements/checklists and the instantiated activities of a requirement within a project. When only the project is known, use `get_project_requirements` first to discover the required `project_requirement_id`. |
29
29
  | **Applications** | `get_applications` / `get_application` | List or fetch applications and their assets. |
30
30
  | **Scans** | `get_scan_histories` / `get_asset_scans_count` | Scan execution history and coverage counts. |
31
31
  | **Supply chain** | `get_sbom_components` | SBOM / dependency components per company. |
32
32
  | **AI-Pentest** | `get_pentest_artifacts` / `get_pentest_artifact` / `get_pentest_execution` | Pentest artifacts, scope and execution results. |
33
33
  | **Threat Modeling** | `get_threat_model_artifacts` / `get_threat_model_artifact` | Threat model artifacts and versions. |
34
+ | **Utilities** | `get_company_id_from_object` | Resolve the owning company from an issue, asset, project, pentest or threat-model object ID before a write. |
34
35
  | **Writes — engine** | `list_mutations` / `describe_mutation` / `execute_mutation` | Discover, describe and run the permitted write operations below. |
35
36
  | **Writes — Issues** | `execute_mutation` | Create, update, delete and change status of vulnerabilities/issues. |
36
37
  | **Writes — Assets** | `execute_mutation` | Create and update assets; run a DAST scan. |
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@convisoappsec/mcp",
3
- "version": "0.6.3",
4
- "mcpName": "io.github.convisoappsec/conviso-mcp",
3
+ "version": "0.7.0",
5
4
  "description": "MCP Server for Conviso Platform integration",
6
5
  "type": "module",
7
6
  "main": "src/conviso_mcp/server.js",
@@ -454,6 +454,29 @@ export function buildProjectsVariables(companyId, page = 1, limit = 1000, opts =
454
454
  return { page, limit, params, sortBy, descending };
455
455
  }
456
456
 
457
+ export function buildProjectRequirementActivitiesVariables(projectId, projectRequirementId, {
458
+ page = 1,
459
+ limit = 10,
460
+ title = '',
461
+ sortBy = 'SORT',
462
+ descending = false,
463
+ attachmentActionsOnly,
464
+ } = {}) {
465
+ return {
466
+ page,
467
+ limit,
468
+ sortBy,
469
+ descending,
470
+ historyPagination: { page: 1, perPage: limit },
471
+ attachmentActionsOnly,
472
+ params: compact({
473
+ projectId,
474
+ projectRequirementId,
475
+ title,
476
+ }),
477
+ };
478
+ }
479
+
457
480
  // Shared HTTP client: keep-alive reuses the TLS connection across the many sequential
458
481
  // calls an agent session makes; the timeout stops a hung upstream from hanging a tool
459
482
  // call (and the MCP client) forever.
@@ -463,6 +486,16 @@ const httpClient = axios.create({
463
486
  });
464
487
 
465
488
  const RETRYABLE_STATUS = new Set([429, 502, 503]);
489
+ const MCP_WRITE_POLICY_FIELD_MISSING =
490
+ "Field 'enableMcpWrite' doesn't exist on type 'PolicyControls'";
491
+
492
+ const MCP_WRITE_POLICY_QUERY = `
493
+ query McpWritePolicy($companyId: ID!) {
494
+ policyControls(companyId: $companyId) {
495
+ enableMcpWrite
496
+ }
497
+ }
498
+ `;
466
499
 
467
500
  class GraphQLClient {
468
501
  constructor(endpoint, apiKey) {
@@ -488,6 +521,82 @@ class GraphQLClient {
488
521
  }
489
522
  }
490
523
 
524
+ async assertMcpWriteEnabled(companyId) {
525
+ if (companyId === undefined || companyId === null) {
526
+ const error = new Error('Could not determine company for MCP write policy check');
527
+ error.status = 400;
528
+ throw error;
529
+ }
530
+ let data;
531
+ try {
532
+ data = await this.execute(MCP_WRITE_POLICY_QUERY, { companyId });
533
+ } catch (error) {
534
+ // Older backends do not expose the policy field yet. Their effective default is
535
+ // enabled, matching the backend default once the field becomes available.
536
+ const fieldNotDeployed = error.graphqlErrors?.some((message) =>
537
+ message.startsWith(MCP_WRITE_POLICY_FIELD_MISSING));
538
+ if (fieldNotDeployed) return;
539
+ throw error;
540
+ }
541
+ if (data?.policyControls?.enableMcpWrite !== true) {
542
+ const error = new Error(`MCP write operations are disabled by company ${companyId} policy`);
543
+ error.status = 403;
544
+ // Explicitly safe to return to the MCP caller. Other upstream errors stay sanitized.
545
+ error.publicMessage = error.message;
546
+ error.authHint = `Enable MCP write operations in the policy controls for company ${companyId}`;
547
+ throw error;
548
+ }
549
+ }
550
+
551
+ async getCompanyIdFromObject(objectType, objectId) {
552
+ // This is deliberately an object inventory, not a mutation inventory. It gives the
553
+ // model an explicit read step when a user supplies an entity ID but no company ID.
554
+ const lookups = {
555
+ issue: {
556
+ query: 'issue(id: $id) { asset { company { id } } }',
557
+ companyId: (data) => data?.issue?.asset?.company?.id,
558
+ },
559
+ asset: {
560
+ query: 'asset(id: $id) { company { id } }',
561
+ companyId: (data) => data?.asset?.company?.id,
562
+ },
563
+ project: {
564
+ query: 'project(id: $id) { company { id } }',
565
+ companyId: (data) => data?.project?.company?.id,
566
+ },
567
+ pentest_artifact: {
568
+ query: 'pentestArtifact(id: $id) { company { id } }',
569
+ companyId: (data) => data?.pentestArtifact?.company?.id,
570
+ },
571
+ pentest_execution: {
572
+ query: 'pentestExecution(id: $id) { project { company { id } } }',
573
+ companyId: (data) => data?.pentestExecution?.project?.company?.id,
574
+ },
575
+ threat_model_artifact: {
576
+ query: 'threatModelArtifact(id: $id) { company { id } }',
577
+ companyId: (data) => data?.threatModelArtifact?.company?.id,
578
+ },
579
+ };
580
+ const lookup = lookups[objectType];
581
+ if (!lookup) {
582
+ const error = new Error(`Unsupported object type '${objectType}'`);
583
+ error.status = 400;
584
+ throw error;
585
+ }
586
+ const data = await this.execute(`
587
+ query CompanyFromObject($id: ID!) {
588
+ ${lookup.query}
589
+ }
590
+ `, { id: objectId });
591
+ const companyId = lookup.companyId(data);
592
+ if (companyId === undefined || companyId === null) {
593
+ const error = new Error(`Could not find a company for ${objectType} '${objectId}'`);
594
+ error.status = 404;
595
+ throw error;
596
+ }
597
+ return { object_type: objectType, object_id: objectId, company_id: Number(companyId) };
598
+ }
599
+
491
600
  async #post(query, variables) {
492
601
  let response;
493
602
  try {
@@ -825,6 +934,36 @@ class GraphQLClient {
825
934
  return this.execute(query, { projectId: project_id });
826
935
  }
827
936
 
937
+ async get_project_requirement_activities(project_id, project_requirement_id, options = {}) {
938
+ const query = `
939
+ query GetProjectRequirementActivities(
940
+ $page: Int
941
+ $limit: Int
942
+ $sortBy: ActivitySortByEnum
943
+ $descending: Boolean
944
+ $historyPagination: PaginationInput!
945
+ $attachmentActionsOnly: Boolean
946
+ $params: ActivitiesSearch!
947
+ ) {
948
+ activities(page: $page, limit: $limit, params: $params, sortBy: $sortBy, descending: $descending) {
949
+ collection {
950
+ id title status permittedStatus description reference updatedAt reason
951
+ history(pagination: $historyPagination, attachmentActionsOnly: $attachmentActionsOnly) {
952
+ metadata { totalCount }
953
+ }
954
+ portalUser { avatarUrl email name }
955
+ check { description id label }
956
+ assignedUsers { avatarUrl name email }
957
+ }
958
+ metadata { currentPage limitValue totalCount totalPages }
959
+ }
960
+ }`;
961
+ return this.execute(
962
+ query,
963
+ buildProjectRequirementActivitiesVariables(project_id, project_requirement_id, options),
964
+ );
965
+ }
966
+
828
967
  async get_applications(company_id, search = null) {
829
968
  const query = `
830
969
  query GetApplications($companyId: ID!, $search: String) {
@@ -36,7 +36,9 @@ function sanitizeError(err, message = 'Request failed') {
36
36
  status,
37
37
  });
38
38
 
39
- const result = { error: message, status, error_id };
39
+ // Only errors deliberately marked by our own code may override the generic tool error.
40
+ // Never expose arbitrary upstream messages here.
41
+ const result = { error: err?.publicMessage || message, status, error_id };
40
42
  // GraphQL errors describe the caller's own request (e.g. a missing required field) — pass
41
43
  // them through so the model can fix the input on the next attempt.
42
44
  if (Array.isArray(err?.graphqlErrors) && err.graphqlErrors.length) {
@@ -62,6 +64,7 @@ function ok(data) {
62
64
  // Shared enum strings so tool descriptions state each list exactly once.
63
65
  const SEVERITIES = 'NOTIFICATION, LOW, MEDIUM, HIGH, CRITICAL';
64
66
  const ISSUE_STATUSES = 'CREATED, DRAFT, IDENTIFIED, IN_PROGRESS, AWAITING_VALIDATION, FIX_ACCEPTED, RISK_ACCEPTED, FALSE_POSITIVE, SUPPRESSED';
67
+ const COMPANY_OBJECT_TYPES = ['issue', 'asset', 'project', 'pentest_artifact', 'pentest_execution', 'threat_model_artifact'];
65
68
 
66
69
  /**
67
70
  * Build a fresh McpServer with all tools registered. stdio mode uses one instance for the
@@ -75,7 +78,10 @@ function buildServer() {
75
78
  });
76
79
 
77
80
  // 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) {
81
+ function tool(name, {
82
+ title, desc, schema, write = false, destructive = false, local = false,
83
+ policy = write, companyId = (args) => args.company_id,
84
+ }, handler) {
79
85
  server.registerTool(
80
86
  name,
81
87
  {
@@ -91,6 +97,8 @@ function buildServer() {
91
97
  },
92
98
  async (args) => {
93
99
  try {
100
+ const checkPolicy = typeof policy === 'function' ? policy(args) : policy;
101
+ if (checkPolicy) await gql.assertMcpWriteEnabled(await companyId(args));
94
102
  return ok(await handler(args));
95
103
  } catch (err) {
96
104
  return ok(sanitizeError(err, `${name} failed`));
@@ -121,6 +129,15 @@ function buildServer() {
121
129
  schema: z.object({ company_id: z.number() }),
122
130
  }, ({ company_id }) => gql.get_company_by_id(company_id));
123
131
 
132
+ tool('get_company_id_from_object', {
133
+ title: 'Resolve Company from Object',
134
+ desc: `Resolve the company_id that owns an existing Conviso object. IMPORTANT: mutation tools require company_id for the MCP write-policy check. When the user asks for a write and provides only an object ID (for example issue_id, asset_id, project_id, pentest artifact/execution ID, or threat-model artifact ID), call this tool first with the matching object_type, then pass the returned company_id to the mutation tool. Do not guess a company_id and do not pass the object ID as company_id. Supported object_type values: ${COMPANY_OBJECT_TYPES.join(', ')}. This lookup is for write preparation; it does not perform a mutation.`,
135
+ schema: z.object({
136
+ object_type: z.enum(COMPANY_OBJECT_TYPES),
137
+ object_id: z.number(),
138
+ }),
139
+ }, ({ object_type, object_id }) => gql.getCompanyIdFromObject(object_type, object_id));
140
+
124
141
  tool('get_issue', {
125
142
  title: 'Issue Details',
126
143
  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).',
@@ -383,10 +400,35 @@ function buildServer() {
383
400
 
384
401
  tool('get_project_requirements', {
385
402
  title: 'Project Requirements',
386
- desc: 'List the requirements/checklists attached to a project.',
403
+ desc: 'List the requirements/checklists attached to a project. The returned project-requirement id is the project_requirement_id used by get_project_requirement_activities. When the user knows only the project, call this tool first, choose the requested requirement from the results (ask only if ambiguous), then call get_project_requirement_activities.',
387
404
  schema: z.object({ project_id: z.number() }),
388
405
  }, ({ project_id }) => gql.get_project_requirements(project_id));
389
406
 
407
+ tool('get_project_requirement_activities', {
408
+ title: 'Project Requirement Activities',
409
+ desc: 'List the instantiated activities for one requirement/checklist within a project, including status, permitted transitions, assignees, references, reason, and history count. Requires the project-requirement association id, not the requirement template id. If the user provides only a project, first call get_project_requirements(project_id) to discover project_requirement_id, then call this tool for the relevant requirement; repeat for each returned requirement when the user asks for all project checklist activities.',
410
+ schema: z.object({
411
+ project_id: z.number(),
412
+ project_requirement_id: z.number(),
413
+ page: z.number().optional(),
414
+ limit: z.number().optional(),
415
+ title: z.string().optional(),
416
+ sort_by: z.string().optional().describe('ActivitySortByEnum value; defaults to SORT.'),
417
+ descending: z.boolean().optional(),
418
+ attachment_actions_only: z.boolean().optional(),
419
+ }),
420
+ }, ({
421
+ project_id, project_requirement_id, page, limit, title, sort_by,
422
+ descending, attachment_actions_only,
423
+ }) => gql.get_project_requirement_activities(project_id, project_requirement_id, {
424
+ page,
425
+ limit,
426
+ title,
427
+ sortBy: sort_by,
428
+ descending,
429
+ attachmentActionsOnly: attachment_actions_only,
430
+ }));
431
+
390
432
  tool('get_applications', {
391
433
  title: 'List Applications',
392
434
  desc: 'List a company\'s applications (name, url, riskScore, assetsCount). Optional: search by name.',
@@ -504,14 +546,16 @@ function buildServer() {
504
546
 
505
547
  tool('execute_mutation', {
506
548
  title: 'Execute Mutation',
507
- 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.',
549
+ desc: 'Run any permitted write operation by name (see list_mutations). company_id is required only for the MCP write-policy check and is not added to the GraphQL mutation input. If the user supplied an object ID but no company, call get_company_id_from_object first. 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.',
508
550
  schema: z.object({
551
+ company_id: z.number(),
509
552
  name: z.string(),
510
553
  variables: z.record(z.string(), z.any()).optional(),
511
554
  return_fields: z.string().optional(),
512
555
  }),
513
556
  write: true,
514
557
  destructive: true,
558
+ policy: ({ name }) => name !== 'createTicket',
515
559
  }, ({ name, variables = {}, return_fields = null }) =>
516
560
  gql.executeMutation(name, variables, return_fields));
517
561
 
@@ -521,8 +565,9 @@ function buildServer() {
521
565
 
522
566
  tool('change_issue_status', {
523
567
  title: 'Change Issue Status',
524
- desc: `Change an issue's status. status: one of ${ISSUE_STATUSES}. Optional reason; extra = advanced ChangeIssueStatusInput fields (e.g. riskAcceptedUntil).`,
568
+ desc: `Change an issue's status. Required: company_id and issue_id; if only issue_id is known, call get_company_id_from_object first. status: one of ${ISSUE_STATUSES}. Optional reason; extra = advanced ChangeIssueStatusInput fields (e.g. riskAcceptedUntil).`,
525
569
  schema: z.object({
570
+ company_id: z.number(),
526
571
  issue_id: z.number(),
527
572
  status: z.string(),
528
573
  reason: z.string().optional(),
@@ -533,8 +578,9 @@ function buildServer() {
533
578
 
534
579
  tool('create_source_code_vulnerability', {
535
580
  title: 'Create Source Code Vulnerability',
536
- 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.`,
581
+ desc: `Create a manual source-code (SAST-style) vulnerability on an asset. Required: company_id and asset_id; if only asset_id is known, call get_company_id_from_object first. severity: ${SEVERITIES}. impact_level/probability_level: LOW, MEDIUM, HIGH (default MEDIUM). status defaults to DRAFT. extra = any other CreateSourceCodeVulnerabilityInput field.`,
537
582
  schema: z.object({
583
+ company_id: z.number(),
538
584
  asset_id: z.number(),
539
585
  title: z.string(),
540
586
  description: z.string(),
@@ -610,19 +656,20 @@ function buildServer() {
610
656
  extra: z.record(z.string(), z.any()).optional(),
611
657
  }),
612
658
  write: true,
659
+ policy: false,
613
660
  }, (a) => gql.create_ticket(a));
614
661
 
615
662
  tool('run_dast', {
616
663
  title: 'Run DAST',
617
- desc: 'Start a Conviso DAST scan on an asset (startConvisoDast). Required: asset_id.',
618
- schema: z.object({ asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
664
+ desc: 'Start a Conviso DAST scan on an asset (startConvisoDast). Required: company_id and asset_id. If only asset_id is known, call get_company_id_from_object first.',
665
+ schema: z.object({ company_id: z.number(), asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
619
666
  write: true,
620
667
  }, (a) => gql.run_dast(a));
621
668
 
622
669
  tool('trigger_pentest', {
623
670
  title: 'Trigger AI-Pentest',
624
- desc: 'Trigger an AI-Pentest execution from an existing pentest artifact (createPentestExecution). Required: artifact_id.',
625
- schema: z.object({ artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
671
+ desc: 'Trigger an AI-Pentest execution from an existing pentest artifact (createPentestExecution). Required: company_id and artifact_id. If only artifact_id is known, call get_company_id_from_object with object_type pentest_artifact first.',
672
+ schema: z.object({ company_id: z.number(), artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
626
673
  write: true,
627
674
  }, (a) => gql.trigger_pentest(a));
628
675