wiRedPanda
Logic Circuit Simulator
Loading...
Searching...
No Matches
MCPValidator.cpp
Go to the documentation of this file.
1// Copyright 2015 - 2026, GIBIS-UNIFESP and the wiRedPanda contributors
2// SPDX-License-Identifier: GPL-3.0-or-later
3
5
6#include <QCoreApplication>
7#include <QDebug>
8#include <QDir>
9#include <QFile>
10#include <QJsonArray>
11#include <QJsonDocument>
12#include <QRegularExpression>
13
15 : m_schemaPath(schemaPath)
16{
17 // Load schema but don't fail constructor if it fails
18 loadSchema(schemaPath);
19}
20
22
23bool MCPValidator::loadSchema(const QString &schemaPath)
24{
25 m_schemaPath = schemaPath;
26 m_schemaLoaded = false;
27
28 QFile schemaFile(schemaPath);
29 if (!schemaFile.open(QIODevice::ReadOnly)) {
30 return false;
31 }
32
33 QByteArray schemaData = schemaFile.readAll();
34 schemaFile.close();
35
36 try {
37 // Parse schema JSON
38 m_schema = json::parse(schemaData.toStdString());
39
40 // Create native validator
41 m_validator = std::make_unique<json_validator>();
42 m_validator->set_root_schema(m_schema);
43
44 // Extract command and response schemas for easier access
45 if (m_schema.contains("properties")) {
46 auto properties = m_schema["properties"];
47 if (properties.contains("commands") && properties["commands"].contains("properties")) {
48 m_commandSchemas = properties["commands"]["properties"];
49 }
50 if (properties.contains("responses") && properties["responses"].contains("properties")) {
51 m_responseSchemas = properties["responses"]["properties"];
52 }
53 }
54
55 m_schemaLoaded = true;
56 return true;
57
58 } catch (const json::parse_error &) {
59 return false;
60 } catch (const json::exception &) {
61 // Empirically, not found reachable: json::parse() failures are always
62 // json::parse_error (caught above); every schema-shape defect tried against the real
63 // json-schema-validator library (invalid "type" values, unresolvable/external $ref)
64 // either compiles silently or throws a plain std::exception (caught below), never a
65 // json::exception subtype. The subsequent .contains()/[] extraction calls on
66 // m_schema also can't throw: nlohmann's contains() returns false (not an exception)
67 // for a non-object value, short-circuiting every nested [] access below it.
68 return false; // LCOV_EXCL_LINE
69 } catch (const std::exception &) {
70 return false;
71 }
72}
73
75{
76 return m_schemaLoaded;
77}
78
80{
81 return m_schemaPath;
82}
83
85{
86 try {
87 return validateCommand(qjsonToNlohmann(command));
88 } catch (const std::exception &e) {
89 return ValidationResult(false, QString("Malformed command: %1").arg(e.what()));
90 }
91}
92
94{
95 if (!m_schemaLoaded) {
96 return ValidationResult(false, "Schema not loaded");
97 }
98
99 // Extract method type (JSON-RPC 2.0 format)
100 QString commandType = "unknown";
101 if (command.contains("method") && command["method"].is_string()) {
102 commandType = QString::fromStdString(command["method"].get<std::string>());
103 } else {
104 return ValidationResult(false, "Missing or invalid 'method' field", "", commandType);
105 }
106
107 // Find the appropriate command schema
108 json commandSchema = findCommandSchema(commandType);
109 if (commandSchema.is_null()) {
110 return ValidationResult(false,
111 QString("No schema found for command type: %1").arg(commandType),
112 "", commandType);
113 }
114
115 // Validate against the command schema using native validator
116 return validateAgainstSchema(command, commandSchema, commandType, "command:" + commandType.toStdString());
117}
118
119ValidationResult MCPValidator::validateResponse(const QJsonObject &response, const QString &expectedCommand)
120{
121 try {
122 return validateResponse(qjsonToNlohmann(response), expectedCommand);
123 } catch (const std::exception &e) {
124 return ValidationResult(false, QString("Malformed response: %1").arg(e.what()));
125 }
126}
127
128ValidationResult MCPValidator::validateResponse(const json &response, const QString &expectedCommand)
129{
130 if (!m_schemaLoaded) {
131 return ValidationResult(false, "Schema not loaded");
132 }
133
134 // First validate against base CommandResponse schema
135 json baseResponseSchema;
136 if (m_schema.contains("definitions") && m_schema["definitions"].contains("CommandResponse")) {
137 baseResponseSchema = m_schema["definitions"]["CommandResponse"];
138 } else {
139 return ValidationResult(false, "CommandResponse schema not found in definitions");
140 }
141
142 ValidationResult baseResult = validateAgainstSchema(response, baseResponseSchema, expectedCommand, "response:base");
143 if (!baseResult.isValid) {
144 return ValidationResult(false,
145 QString("Base response validation failed: %1").arg(baseResult.errorMessage),
146 "base_response", expectedCommand);
147 }
148
149 // If we have a specific expected command, validate against specific response schema.
150 // Response schemas are keyed by "<command>_response" in the schema (see
151 // ServerInfoHandler::describeCommand()'s identical lookup) -- passing the bare command
152 // name here always missed, silently skipping specific response validation entirely.
153 if (!expectedCommand.isEmpty()) {
154 json responseSchema = findResponseSchema(expectedCommand + "_response");
155 if (!responseSchema.is_null()) {
156 ValidationResult specificResult = validateAgainstSchema(response, responseSchema, expectedCommand, "response:" + expectedCommand.toStdString());
157 if (!specificResult.isValid) {
158 return ValidationResult(false,
159 QString("Specific response validation failed: %1").arg(specificResult.errorMessage),
160 expectedCommand + "_response", expectedCommand);
161 }
162 return ValidationResult(true, "", expectedCommand + "_response", expectedCommand);
163 }
164 }
165
166 return ValidationResult(true, "", "base_response", expectedCommand);
167}
168
169ValidationResult MCPValidator::validateAgainstSchema(const json &data, const json &schema, const QString &commandType, const std::string &cacheKey)
170{
171 try {
172 // Use the main validator which has access to all definitions for $ref resolution
173 if (m_validator) {
174 json_validator *validator = nullptr;
175
176 // Schemas are immutable after load — compile each one once (F44);
177 // a fresh json_validator per call was the dominant per-request cost.
178 const auto it = m_validatorCache.find(cacheKey);
179 if (it != m_validatorCache.end()) {
180 validator = it->second.get();
181 } else {
182 // For schema references that may contain $ref, we need to create a complete schema
183 json fullSchema = schema;
184
185 // If this is a sub-schema without definitions but contains $ref (directly or
186 // nested under allOf/anyOf/oneOf, as every "<command>_response" schema does via
187 // its "$ref": "#/definitions/CommandResponse" combinator), we need to include
188 // the definitions section. Attaching it unconditionally (rather than gating on
189 // "properties", which misses allOf/anyOf/oneOf-shaped schemas entirely) is
190 // harmless for schemas that don't need it -- an unreferenced "definitions" key
191 // has no effect on validation.
192 if (m_schema.contains("definitions")) {
193 fullSchema["definitions"] = m_schema["definitions"];
194 fullSchema["$schema"] = m_schema["$schema"];
195 }
196
197 auto compiled = std::make_unique<json_validator>();
198 compiled->set_root_schema(fullSchema);
199 validator = m_validatorCache.emplace(cacheKey, std::move(compiled)).first->second.get();
200 }
201
202 // This throws on validation failure with detailed error messages
203 validator->validate(data);
204 }
205
206 return ValidationResult(true, "", "", commandType);
207
208 } catch (const std::exception &e) {
209 // Native error messages are much better than our manual ones
210 QString errorMsg = QString::fromStdString(e.what());
211 QString errorPath = extractErrorPath(errorMsg);
212
213 return ValidationResult(false, errorMsg, "", commandType, errorPath);
214 }
215}
216
217json MCPValidator::findCommandSchema(const QString &commandName) const
218{
219 if (!m_commandSchemas.is_null() && m_commandSchemas.contains(commandName.toStdString())) {
220 json schema = m_commandSchemas[commandName.toStdString()];
221
222 // Handle $ref resolution
223 if (schema.contains("$ref") && schema["$ref"].is_string()) {
224 QString refPath = QString::fromStdString(schema["$ref"].get<std::string>());
225 if (refPath.startsWith("#/definitions/")) {
226 QString defName = refPath.mid(14); // Remove "#/definitions/"
227 if (m_schema.contains("definitions") && m_schema["definitions"].contains(defName.toStdString())) {
228 return m_schema["definitions"][defName.toStdString()];
229 }
230 }
231 }
232
233 return schema;
234 }
235
236 return json();
237}
238
239json MCPValidator::findResponseSchema(const QString &commandName) const
240{
241 if (!m_responseSchemas.is_null() && m_responseSchemas.contains(commandName.toStdString())) {
242 json schema = m_responseSchemas[commandName.toStdString()];
243
244 // Handle $ref resolution
245 if (schema.contains("$ref") && schema["$ref"].is_string()) {
246 QString refPath = QString::fromStdString(schema["$ref"].get<std::string>());
247 if (refPath.startsWith("#/definitions/")) {
248 QString defName = refPath.mid(14); // Remove "#/definitions/"
249 if (m_schema.contains("definitions") && m_schema["definitions"].contains(defName.toStdString())) {
250 return m_schema["definitions"][defName.toStdString()];
251 }
252 }
253 }
254
255 return schema;
256 }
257
258 return json();
259}
260
261QString MCPValidator::extractErrorPath(const QString &errorMsg)
262{
263 // Extract JSON path from error message using regex. The native validator's real message
264 // format is "At <path> of <value> - <reason>" (e.g. "At /x of \"y\" - unexpected instance
265 // type", or "At of {...} - ..." with an empty path at the root) -- confirmed empirically
266 // against the library, not assumed from the older "at '/path'" comment below, which no
267 // real message this validator produces ever matches (case mismatch and no quotes).
268 static const QRegularExpression pathRegex(R"(^At\s(\S*)\s+of\s)");
269 QRegularExpressionMatch match = pathRegex.match(errorMsg);
270 if (match.hasMatch()) {
271 return match.captured(1);
272 }
273
274 // Empirically, not found reachable: this is only ever called with a real
275 // json_validator::validate() failure message (every violation kind tried -- required,
276 // type, enum, additionalProperties, oneOf -- consistently used the "At ... of ... - ..."
277 // format above). A schema-*compilation* failure (a different message shape, e.g. an
278 // unresolvable $ref) can't reach here either: any such $ref would already have made
279 // set_root_schema() reject the whole document at load time, so m_commandSchemas/
280 // m_responseSchemas could never contain an entry whose own compilation could fail this way.
281 return QString(); // LCOV_EXCL_LINE
282}
283
284json MCPValidator::qjsonToNlohmann(const QJsonObject &qjson)
285{
286 QJsonDocument doc(qjson);
287 QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
288 try {
289 return json::parse(jsonData.toStdString());
290 } catch (const json::parse_error &e) {
291 throw std::runtime_error(std::string("qjsonToNlohmann: JSON parse error: ") + e.what());
292 }
293}
294
295QJsonObject MCPValidator::nlohmannToQJson(const json &nlohmannJson)
296{
297 std::string jsonString = nlohmannJson.dump();
298 QJsonParseError parseError;
299 QJsonDocument doc = QJsonDocument::fromJson(QByteArray::fromStdString(jsonString), &parseError);
300 if (parseError.error != QJsonParseError::NoError || doc.isNull()) {
301 qWarning() << "nlohmannToQJson: failed to parse JSON:" << parseError.errorString();
302 return {};
303 }
304 return doc.object();
305}
nlohmann::json json
static json qjsonToNlohmann(const QJsonObject &qjson)
ValidationResult validateCommand(const QJsonObject &command)
json findResponseSchema(const QString &commandName) const
MCPValidator(const QString &schemaPath)
static QJsonObject nlohmannToQJson(const json &nlohmannJson)
QString schemaPath() const
bool isSchemaLoaded() const
ValidationResult validateResponse(const QJsonObject &response, const QString &expectedCommand=QString())
json findCommandSchema(const QString &commandName) const
Result of JSON schema validation.