@convisoappsec/mcp 0.6.2 → 0.6.4

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
@@ -31,6 +31,7 @@ The server exposes the following tools to the LLM (see `node/manifest.json` for
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@convisoappsec/mcp",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "MCP Server for Conviso Platform integration",
5
5
  "type": "module",
6
6
  "main": "src/conviso_mcp/server.js",
@@ -463,6 +463,16 @@ const httpClient = axios.create({
463
463
  });
464
464
 
465
465
  const RETRYABLE_STATUS = new Set([429, 502, 503]);
466
+ const MCP_WRITE_POLICY_FIELD_MISSING =
467
+ "Field 'enableMcpWrite' doesn't exist on type 'PolicyControls'";
468
+
469
+ const MCP_WRITE_POLICY_QUERY = `
470
+ query McpWritePolicy($companyId: ID!) {
471
+ policyControls(companyId: $companyId) {
472
+ enableMcpWrite
473
+ }
474
+ }
475
+ `;
466
476
 
467
477
  class GraphQLClient {
468
478
  constructor(endpoint, apiKey) {
@@ -488,6 +498,82 @@ class GraphQLClient {
488
498
  }
489
499
  }
490
500
 
501
+ async assertMcpWriteEnabled(companyId) {
502
+ if (companyId === undefined || companyId === null) {
503
+ const error = new Error('Could not determine company for MCP write policy check');
504
+ error.status = 400;
505
+ throw error;
506
+ }
507
+ let data;
508
+ try {
509
+ data = await this.execute(MCP_WRITE_POLICY_QUERY, { companyId });
510
+ } catch (error) {
511
+ // Older backends do not expose the policy field yet. Their effective default is
512
+ // enabled, matching the backend default once the field becomes available.
513
+ const fieldNotDeployed = error.graphqlErrors?.some((message) =>
514
+ message.startsWith(MCP_WRITE_POLICY_FIELD_MISSING));
515
+ if (fieldNotDeployed) return;
516
+ throw error;
517
+ }
518
+ if (data?.policyControls?.enableMcpWrite !== true) {
519
+ const error = new Error(`MCP write operations are disabled by company ${companyId} policy`);
520
+ error.status = 403;
521
+ // Explicitly safe to return to the MCP caller. Other upstream errors stay sanitized.
522
+ error.publicMessage = error.message;
523
+ error.authHint = `Enable MCP write operations in the policy controls for company ${companyId}`;
524
+ throw error;
525
+ }
526
+ }
527
+
528
+ async getCompanyIdFromObject(objectType, objectId) {
529
+ // This is deliberately an object inventory, not a mutation inventory. It gives the
530
+ // model an explicit read step when a user supplies an entity ID but no company ID.
531
+ const lookups = {
532
+ issue: {
533
+ query: 'issue(id: $id) { asset { company { id } } }',
534
+ companyId: (data) => data?.issue?.asset?.company?.id,
535
+ },
536
+ asset: {
537
+ query: 'asset(id: $id) { company { id } }',
538
+ companyId: (data) => data?.asset?.company?.id,
539
+ },
540
+ project: {
541
+ query: 'project(id: $id) { company { id } }',
542
+ companyId: (data) => data?.project?.company?.id,
543
+ },
544
+ pentest_artifact: {
545
+ query: 'pentestArtifact(id: $id) { company { id } }',
546
+ companyId: (data) => data?.pentestArtifact?.company?.id,
547
+ },
548
+ pentest_execution: {
549
+ query: 'pentestExecution(id: $id) { project { company { id } } }',
550
+ companyId: (data) => data?.pentestExecution?.project?.company?.id,
551
+ },
552
+ threat_model_artifact: {
553
+ query: 'threatModelArtifact(id: $id) { company { id } }',
554
+ companyId: (data) => data?.threatModelArtifact?.company?.id,
555
+ },
556
+ };
557
+ const lookup = lookups[objectType];
558
+ if (!lookup) {
559
+ const error = new Error(`Unsupported object type '${objectType}'`);
560
+ error.status = 400;
561
+ throw error;
562
+ }
563
+ const data = await this.execute(`
564
+ query CompanyFromObject($id: ID!) {
565
+ ${lookup.query}
566
+ }
567
+ `, { id: objectId });
568
+ const companyId = lookup.companyId(data);
569
+ if (companyId === undefined || companyId === null) {
570
+ const error = new Error(`Could not find a company for ${objectType} '${objectId}'`);
571
+ error.status = 404;
572
+ throw error;
573
+ }
574
+ return { object_type: objectType, object_id: objectId, company_id: Number(companyId) };
575
+ }
576
+
491
577
  async #post(query, variables) {
492
578
  let response;
493
579
  try {
@@ -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).',
@@ -504,14 +521,16 @@ function buildServer() {
504
521
 
505
522
  tool('execute_mutation', {
506
523
  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.',
524
+ 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
525
  schema: z.object({
526
+ company_id: z.number(),
509
527
  name: z.string(),
510
528
  variables: z.record(z.string(), z.any()).optional(),
511
529
  return_fields: z.string().optional(),
512
530
  }),
513
531
  write: true,
514
532
  destructive: true,
533
+ policy: ({ name }) => name !== 'createTicket',
515
534
  }, ({ name, variables = {}, return_fields = null }) =>
516
535
  gql.executeMutation(name, variables, return_fields));
517
536
 
@@ -521,8 +540,9 @@ function buildServer() {
521
540
 
522
541
  tool('change_issue_status', {
523
542
  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).`,
543
+ 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
544
  schema: z.object({
545
+ company_id: z.number(),
526
546
  issue_id: z.number(),
527
547
  status: z.string(),
528
548
  reason: z.string().optional(),
@@ -533,8 +553,9 @@ function buildServer() {
533
553
 
534
554
  tool('create_source_code_vulnerability', {
535
555
  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.`,
556
+ 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
557
  schema: z.object({
558
+ company_id: z.number(),
538
559
  asset_id: z.number(),
539
560
  title: z.string(),
540
561
  description: z.string(),
@@ -610,19 +631,20 @@ function buildServer() {
610
631
  extra: z.record(z.string(), z.any()).optional(),
611
632
  }),
612
633
  write: true,
634
+ policy: false,
613
635
  }, (a) => gql.create_ticket(a));
614
636
 
615
637
  tool('run_dast', {
616
638
  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() }),
639
+ 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.',
640
+ schema: z.object({ company_id: z.number(), asset_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
619
641
  write: true,
620
642
  }, (a) => gql.run_dast(a));
621
643
 
622
644
  tool('trigger_pentest', {
623
645
  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() }),
646
+ 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.',
647
+ schema: z.object({ company_id: z.number(), artifact_id: z.number(), extra: z.record(z.string(), z.any()).optional() }),
626
648
  write: true,
627
649
  }, (a) => gql.trigger_pentest(a));
628
650