@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 +2 -1
- package/package.json +1 -2
- package/src/conviso_mcp/graphql_client.js +139 -0
- package/src/conviso_mcp/server.js +57 -10
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
|
|
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
|
@@ -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
|
-
|
|
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).',
|
|
@@ -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
|
|