From 09d9f29a2baf7f4ad19188a21aa433bf726b2f7f Mon Sep 17 00:00:00 2001 From: agent-qv Date: Wed, 12 Aug 2026 17:26:40 +0200 Subject: [PATCH 1/2] Add Incident tools mirroring UserRequest (create, update, search) Adds iTop-create-incident, iTop-update-incident and iTop-search-incident-by-caller-status-start-date, duplicating the existing UserRequest tools and their Twig templates. The search OQL uses alias I instead of U for readability, and its tool docstring points to get-class-schema('Incident') since Incident statuses differ from UserRequest's. --- src/Tools/core/create/iTopCreateTools.php | 12 ++++++++++ src/Tools/core/get/iTopGetTools.php | 24 +++++++++++++++++++ src/Tools/core/update/iTopUpdateTools.php | 23 ++++++++++++++++++ templates/Incident-output.toon.twig | 12 ++++++++++ templates/createIncident-input.json.twig | 14 +++++++++++ ...cidentFromCallerStatusDate-input.json.twig | 6 +++++ templates/updateIncident-input.json.twig | 12 ++++++++++ 7 files changed, 103 insertions(+) create mode 100644 templates/Incident-output.toon.twig create mode 100644 templates/createIncident-input.json.twig create mode 100644 templates/searchIncidentFromCallerStatusDate-input.json.twig create mode 100644 templates/updateIncident-input.json.twig diff --git a/src/Tools/core/create/iTopCreateTools.php b/src/Tools/core/create/iTopCreateTools.php index 1457ad9..8800a9b 100644 --- a/src/Tools/core/create/iTopCreateTools.php +++ b/src/Tools/core/create/iTopCreateTools.php @@ -52,4 +52,16 @@ public function createUserRequest(string $title, string $description, int $calle ['title' => $title, 'description' => $description, 'caller_id' => $callerId, 'org_id' => $orgId, 'impact' => $impact, 'urgency' => $urgency] ); } + + /** + * This tool creates an Incident in iTop + */ + #[McpTool(name: TOOL_PREFIX.'create-incident', annotations: new ToolAnnotations(null, false, true, false, false))] + public function createIncident(string $title, string $description, int $callerId, int $orgId, int $impact = 3, int $urgency = 4): string + { + $this->mcpLogger->info('[Tool called] create-incident'); + return $this->runToolFromTemplates('createIncident', 'Incident', + ['title' => $title, 'description' => $description, 'caller_id' => $callerId, 'org_id' => $orgId, 'impact' => $impact, 'urgency' => $urgency] + ); + } } \ No newline at end of file diff --git a/src/Tools/core/get/iTopGetTools.php b/src/Tools/core/get/iTopGetTools.php index 0a5e031..993d58a 100644 --- a/src/Tools/core/get/iTopGetTools.php +++ b/src/Tools/core/get/iTopGetTools.php @@ -114,4 +114,28 @@ public function searchUserRequestByCallerStatusStartDate(#[Schema(format: 'email ] ); } + + /** + * This tool searches for existing Incidents in iTop based on: + * - the email of the caller + * - an optional list of statuses for the Incident. + * - an optional start date + * - if a start date is specified, the condition/operator: either 'greater_than' or 'less_than' + * @param string $caller_email The email of the caller + * @param string[] $statuses Optional, a list of status codes (multiple values will be combined with a OR condition). Call get-class-schema('Incident') to find the possible values for the status field. + * @param string $start_date Optional, the start date-time of the Incident (format as a MySQL DateTime (YYYY-MM-DD hh:ii:ss) + * @param string $condition Optional, the condiotnal operator for the start date: either 'greater_than" or 'less_than' + */ + #[McpTool(name: TOOL_PREFIX.'search-incident-by-caller-status-start-date', annotations: new ToolAnnotations(null, true, false, true, false))] + public function searchIncidentByCallerStatusStartDate(#[Schema(format: 'email')] string $caller_email, array $statuses = [], string $start_date = '', string $condition = 'greater_than'): string + { + return $this->runToolFromTemplates('searchIncidentFromCallerStatusDate', 'Incident', + [ + 'caller_email' => $caller_email, + 'statuses' => count($statuses) > 0 ? "'".implode("','", $statuses)."'" : '', + 'start_date' => $start_date, + 'condition' => $condition === 'greater_than' ? '>=' : '<=', + ] + ); + } } \ No newline at end of file diff --git a/src/Tools/core/update/iTopUpdateTools.php b/src/Tools/core/update/iTopUpdateTools.php index 9f794e1..361fcd3 100644 --- a/src/Tools/core/update/iTopUpdateTools.php +++ b/src/Tools/core/update/iTopUpdateTools.php @@ -45,6 +45,29 @@ public function updateUserRequest(int $id, string $additionalLogEntryHtml, strin ); } + /** + * This tool updates an Incident in iTop by adding an entry to the log and optionally updating the title or the description + * @param int $id The identifier of the Incident to update + * @param string $additionalLogEntryHtml An new entry to add to the log of the Incident - NOTE the log entry MUST BE formatted in HTML + * @param string $title Specify the new title for the Incident (leave empty for no change) + * @param string $descriptionHtml Specify the new description for the Incident (leave empty for no change) - NOTE the descriptipon MUST BE formatted in HTML + */ + #[McpTool(name: TOOL_PREFIX.'update-incident', annotations: new ToolAnnotations(null, false, true, false, false))] + public function updateIncident(int $id, string $additionalLogEntryHtml, string $title = '', string $descriptionHtml = ''): string + { + $this->mcpLogger->info('[Tool called] update-incident'); + return $this->runToolFromTemplates('updateIncident', 'Anything', + [ + 'id' => $id, + 'title' => $title, + 'description' => $descriptionHtml, + 'log' => $additionalLogEntryHtml, + 'output_fields' => $this->datamodel->getListZlist('Incident'), + 'log_field' => 'public_log', + ] + ); + } + /** * This tool can update any object in iTop. * diff --git a/templates/Incident-output.toon.twig b/templates/Incident-output.toon.twig new file mode 100644 index 0000000..2a16e5b --- /dev/null +++ b/templates/Incident-output.toon.twig @@ -0,0 +1,12 @@ +{% if json.objects === null %} +Sorry, no such Incident exists in iTop. +{% else %} +{% for obj in json.objects %} +class Incident: + id,ref,title,status,caller_id_friendlyname,org_id_friendlyname,priority + +Incident { + {{obj.key}}, {{obj.fields.ref}}, "{{obj.fields.title|e('js')}}", {{obj.fields.status}}, "{{obj.fields.caller_id_friendlyname|e('js')}}" , "{{obj.fields.org_id_friendlyname|e('js')}}", {{obj.fields.priority}} +} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/templates/createIncident-input.json.twig b/templates/createIncident-input.json.twig new file mode 100644 index 0000000..ffaa326 --- /dev/null +++ b/templates/createIncident-input.json.twig @@ -0,0 +1,14 @@ +{ + "operation":"core/create", + "class":"Incident", + "output_fields": "ref,title,description,status,caller_id_friendlyname,org_id_friendlyname,priority", + "fields": { + "org_id": {{org_id}}, + "caller_id": {{caller_id}}, + "title": "{{title}}", + "description": "{{description}}", + "impact": {{impact}}, + "urgency": {{urgency}} + }, + "comment": "Created via the MCP server" +} \ No newline at end of file diff --git a/templates/searchIncidentFromCallerStatusDate-input.json.twig b/templates/searchIncidentFromCallerStatusDate-input.json.twig new file mode 100644 index 0000000..10a8f0e --- /dev/null +++ b/templates/searchIncidentFromCallerStatusDate-input.json.twig @@ -0,0 +1,6 @@ +{ + "operation":"core/get", + "class": "Incident", + "key": "SELECT Incident AS I JOIN Person AS P ON I.caller_id = P.id WHERE P.email='{{caller_email}}'{% if statuses !== ''%} AND I.status IN({{ statuses }}){% endif%}{% if start_date !== '' %} AND I.start_date {{ condition }} '{{start_date}}'{% endif%}", + "output_fields": "id,ref,title,status,caller_id_friendlyname,org_id_friendlyname,priority" +} \ No newline at end of file diff --git a/templates/updateIncident-input.json.twig b/templates/updateIncident-input.json.twig new file mode 100644 index 0000000..98b64d5 --- /dev/null +++ b/templates/updateIncident-input.json.twig @@ -0,0 +1,12 @@ +{ + "operation": "core/update", + "class": "Incident", + "key": {{id}}, + "fields": { +{% if title !== '' %} "title":"{{title}}",{% endif %} +{% if description !== '' %} "description":"{{description}}",{% endif %} + "{{ log_field }}":"{{ log }}" + }, + "output_fields": "{{output_fields}}", + "comment": "Updated via the MCP server" +} \ No newline at end of file From 25bb0e84bd877aa90db12fbbc6508c90929b3fbb Mon Sep 17 00:00:00 2001 From: agent-qv Date: Wed, 2 Sep 2026 09:49:31 +0200 Subject: [PATCH 2/2] Escape OQL literals in the new Incident search tool searchIncidentByCallerStatusStartDate interpolates caller_email/statuses/start_date into an OQL query string, the same pattern PR #8 fixes for the pre-existing UserRequest/Person tools. #8 can't cover this site (it's based on master before Incident tools exist), so this PR escapes it locally rather than shipping a new instance of the same class of issue. escapeOqlLiteral() is duplicated from #8 rather than shared, since these are two independent, isolated proposals - intentional and temporary, to be collapsed into one definition once both land. --- src/Tools/core/get/iTopGetTools.php | 5 +++-- src/Tools/iTopRestTools.php | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Tools/core/get/iTopGetTools.php b/src/Tools/core/get/iTopGetTools.php index 993d58a..ed2e6b8 100644 --- a/src/Tools/core/get/iTopGetTools.php +++ b/src/Tools/core/get/iTopGetTools.php @@ -129,11 +129,12 @@ public function searchUserRequestByCallerStatusStartDate(#[Schema(format: 'email #[McpTool(name: TOOL_PREFIX.'search-incident-by-caller-status-start-date', annotations: new ToolAnnotations(null, true, false, true, false))] public function searchIncidentByCallerStatusStartDate(#[Schema(format: 'email')] string $caller_email, array $statuses = [], string $start_date = '', string $condition = 'greater_than'): string { + $statuses = array_map(fn($status) => $this->escapeOqlLiteral((string)$status), $statuses); return $this->runToolFromTemplates('searchIncidentFromCallerStatusDate', 'Incident', [ - 'caller_email' => $caller_email, + 'caller_email' => $this->escapeOqlLiteral($caller_email), 'statuses' => count($statuses) > 0 ? "'".implode("','", $statuses)."'" : '', - 'start_date' => $start_date, + 'start_date' => $this->escapeOqlLiteral($start_date), 'condition' => $condition === 'greater_than' ? '>=' : '<=', ] ); diff --git a/src/Tools/iTopRestTools.php b/src/Tools/iTopRestTools.php index 1398397..d9525b5 100644 --- a/src/Tools/iTopRestTools.php +++ b/src/Tools/iTopRestTools.php @@ -36,4 +36,21 @@ protected function postJsonToItop(string $json): string { return $this->iTopClient->postJsonToItop($json); } + + /** + * Removes the characters that would let a value break out of its OQL string literal: + * quotes, the escape character, and control characters. Meant for exact-match values + * (equality, IN lists) - a LIKE pattern also needs its wildcards (%, _) stripped, + * which callers must handle themselves. + * + * Duplicated here (rather than shared) because this PR and #8 are independent, + * isolated proposals - see this PR's discussion for why, and the intent to collapse + * the duplication once both land. + */ + protected function escapeOqlLiteral(string $value): string + { + $value = str_replace(["\\", "'", '"'], '', $value); + $value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value); + return trim($value); + } } \ No newline at end of file