@convisoappsec/mcp 0.6.3 → 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 +1 -0
- package/package.json +1 -2
- package/src/conviso_mcp/graphql_client.js +86 -0
- package/src/conviso_mcp/server.js +31 -9
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
|
@@ -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
|
-
|
|
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, {
|
|
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
|
|