Skip to content

Commit 04a9f29

Browse files
author
Nicola Spieser
committed
feat: tool input validation (required, types, enum, ranges, lengths) — 52 new tests — bump to v0.34.0
Add MCPValidation.h: lightweight JSON Schema validator for tool call arguments. Validates required fields, type constraints, enum values, numeric ranges (minimum/maximum), string lengths (minLength/maxLength), array sizes (minItems/maxItems), and nested object schemas — all before invoking handlers. Opt-in via enableInputValidation(). Invalid calls get clear -32602 errors with field-level messages. Designed for MCU: no heavy schema libraries. 52 new tests (unit + server integration). Total: 1198 tests.
1 parent f7789cb commit 04a9f29

8 files changed

Lines changed: 886 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.34.0] — 2026-02-22
6+
7+
### Added
8+
- **Tool Input Validation** (`MCPValidation.h`): Lightweight JSON Schema validation for tool call arguments. Validates required fields, types (`string`, `number`, `integer`, `boolean`, `array`, `object`, `null`), `enum` constraints, numeric ranges (`minimum`/`maximum`), string lengths (`minLength`/`maxLength`), array sizes (`minItems`/`maxItems`), and nested object schemas — all before invoking the tool handler.
9+
- `enableInputValidation()` on Server: opt-in validation that checks arguments against declared `inputSchema`. Invalid calls receive a clear JSON-RPC `-32602` error with detailed field-level messages.
10+
- `ValidationResult` with `toString()` for human-readable errors and `toJson()` for structured error data.
11+
- Recursive nested object validation with dotted field paths (e.g. `config.interval`).
12+
- Designed for MCU: no heap-heavy schema libraries, just practical checks.
13+
- **52 new tests** covering all validation checks (required, types, enum, ranges, lengths, arrays, nesting, error formatting) and 8 Server integration tests. **Total: 1198 tests**.
14+
515
## [0.33.1] — 2026-02-22
616

717
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
|---|:---:|:---:|:---:|
3434
| Runs on the MCU ||| ❌ CLI tool |
3535
| MCP spec compliant | ✅ 2025-11-25 | ❌ custom WS ||
36-
| Actually compiles |1146 tests | ❌ self-described | N/A |
36+
| Actually compiles |1198 tests | ❌ self-described | N/A |
3737
| Streamable HTTP + SSE ||||
3838
| WebSocket transport ||||
3939
| Claude Desktop bridge ||||

docs/API.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,38 @@ mcp.addIcon(MCPIcon("https://example.com/icon.png", "image/png")
460460
| `-32600` | Invalid request (missing jsonrpc version or method) |
461461
| `-32601` | Method not found |
462462
| `-32602` | Invalid params (missing tool name, tool not found, etc.) |
463+
464+
### Input Validation
465+
466+
Optional validation of tool call arguments against their declared `inputSchema`.
467+
468+
```cpp
469+
mcp.enableInputValidation(); // Enable before begin()
470+
471+
// Now tools with inputSchema get automatic validation:
472+
mcp.addTool("gpio_write", "Write a GPIO pin",
473+
R"({"type":"object","properties":{
474+
"pin":{"type":"integer","minimum":0,"maximum":39},
475+
"value":{"type":"integer","enum":[0,1]}
476+
},"required":["pin","value"]})",
477+
[](const JsonObject& args) -> String {
478+
// Handler only called if validation passes
479+
return "ok";
480+
});
481+
```
482+
483+
**Supported checks:**
484+
- `required` — all required fields must be present and non-null
485+
- `type` — `string`, `number`, `integer`, `boolean`, `array`, `object`, `null`
486+
- `enum` — value must be one of the listed options
487+
- `minimum` / `maximum` — numeric range constraints
488+
- `minLength` / `maxLength` — string length constraints
489+
- `minItems` / `maxItems` — array length constraints
490+
- Recursive nested `object` validation with dotted field paths
491+
492+
**Error response example:**
493+
```json
494+
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid arguments: 'pin' must be <= 39.00; 'value' must be one of [0, 1]"}}
495+
```
496+
497+
Disabled by default for backward compatibility. Enable with `mcp.enableInputValidation()`.

src/MCPValidation.h

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* mcpd — Tool Input Validation
3+
*
4+
* Lightweight JSON Schema validation for tool call arguments.
5+
* Validates required fields and basic type constraints against the
6+
* declared inputSchema before invoking the tool handler.
7+
*
8+
* Designed for MCU environments: no heap-heavy schema libraries,
9+
* just practical checks that catch common caller mistakes early.
10+
*
11+
* Supported checks:
12+
* - required: all required fields must be present
13+
* - type: string, number, integer, boolean, array, object, null
14+
* - enum: value must be one of the listed options (string enums)
15+
* - minimum/maximum: numeric range constraints
16+
* - minLength/maxLength: string length constraints
17+
* - minItems/maxItems: array length constraints
18+
* - pattern: not supported (too heavy for MCU)
19+
*/
20+
21+
#ifndef MCPD_VALIDATION_H
22+
#define MCPD_VALIDATION_H
23+
24+
#include <Arduino.h>
25+
#include <ArduinoJson.h>
26+
#include <vector>
27+
28+
namespace mcpd {
29+
30+
struct ValidationError {
31+
String field; // Field path (e.g. "temperature" or "config.mode")
32+
String message; // Human-readable error description
33+
34+
ValidationError() = default;
35+
ValidationError(const String& f, const String& m) : field(f), message(m) {}
36+
};
37+
38+
struct ValidationResult {
39+
bool valid = true;
40+
std::vector<ValidationError> errors;
41+
42+
void addError(const String& field, const String& message) {
43+
valid = false;
44+
errors.emplace_back(field, message);
45+
}
46+
47+
/**
48+
* Format all errors as a single string for JSON-RPC error messages.
49+
*/
50+
String toString() const {
51+
if (valid) return "OK";
52+
String result = "Invalid arguments: ";
53+
for (size_t i = 0; i < errors.size(); i++) {
54+
if (i > 0) result += "; ";
55+
if (!errors[i].field.isEmpty()) {
56+
result += "'" + errors[i].field + "' ";
57+
}
58+
result += errors[i].message;
59+
}
60+
return result;
61+
}
62+
63+
/**
64+
* Serialize errors to JSON for structured error data.
65+
*/
66+
String toJson() const {
67+
JsonDocument doc;
68+
JsonArray arr = doc["validationErrors"].to<JsonArray>();
69+
for (const auto& err : errors) {
70+
JsonObject obj = arr.add<JsonObject>();
71+
obj["field"] = err.field;
72+
obj["message"] = err.message;
73+
}
74+
String result;
75+
serializeJson(doc, result);
76+
return result;
77+
}
78+
};
79+
80+
/**
81+
* Validate a JSON value against a type string.
82+
* Returns true if the value matches the expected type.
83+
*/
84+
inline bool validateType(JsonVariant value, const char* expectedType) {
85+
if (!expectedType) return true;
86+
87+
if (strcmp(expectedType, "string") == 0) return value.is<const char*>();
88+
if (strcmp(expectedType, "number") == 0) return value.is<float>() || value.is<double>() || value.is<int>() || value.is<long>();
89+
if (strcmp(expectedType, "integer") == 0) return value.is<int>() || value.is<long>();
90+
if (strcmp(expectedType, "boolean") == 0) return value.is<bool>();
91+
if (strcmp(expectedType, "array") == 0) return value.is<JsonArray>();
92+
if (strcmp(expectedType, "object") == 0) return value.is<JsonObject>();
93+
if (strcmp(expectedType, "null") == 0) return value.isNull();
94+
95+
return true; // Unknown type → pass
96+
}
97+
98+
/**
99+
* Get a human-readable type name for a JSON value.
100+
*/
101+
inline const char* jsonTypeName(JsonVariant value) {
102+
if (value.isNull()) return "null";
103+
if (value.is<bool>()) return "boolean";
104+
if (value.is<const char*>()) return "string";
105+
if (value.is<JsonArray>()) return "array";
106+
if (value.is<JsonObject>()) return "object";
107+
if (value.is<int>() || value.is<long>()) return "integer";
108+
if (value.is<float>() || value.is<double>()) return "number";
109+
return "unknown";
110+
}
111+
112+
/**
113+
* Validate tool arguments against a parsed JSON Schema.
114+
*
115+
* @param args The arguments object from tools/call
116+
* @param schema The parsed inputSchema (must be type: "object")
117+
* @param prefix Field path prefix for nested validation
118+
* @return ValidationResult with any errors found
119+
*/
120+
inline ValidationResult validateArguments(JsonObject args, JsonObject schema,
121+
const String& prefix = "") {
122+
ValidationResult result;
123+
124+
// Check required fields
125+
if (schema.containsKey("required")) {
126+
JsonArray required = schema["required"].as<JsonArray>();
127+
for (JsonVariant req : required) {
128+
const char* fieldName = req.as<const char*>();
129+
if (!fieldName) continue;
130+
131+
String fullPath = prefix.isEmpty() ? String(fieldName)
132+
: prefix + "." + fieldName;
133+
134+
if (!args.containsKey(fieldName) || args[fieldName].isNull()) {
135+
result.addError(fullPath, "is required");
136+
}
137+
}
138+
}
139+
140+
// Check property types and constraints
141+
if (schema.containsKey("properties")) {
142+
JsonObject properties = schema["properties"].as<JsonObject>();
143+
144+
for (JsonPair prop : properties) {
145+
const char* fieldName = prop.key().c_str();
146+
if (!args.containsKey(fieldName)) continue; // Missing optional fields are fine
147+
148+
JsonVariant value = args[fieldName];
149+
JsonObject propSchema = prop.value().as<JsonObject>();
150+
String fullPath = prefix.isEmpty() ? String(fieldName)
151+
: prefix + "." + fieldName;
152+
153+
// Skip null values for optional fields
154+
if (value.isNull()) continue;
155+
156+
// Type check
157+
if (propSchema.containsKey("type")) {
158+
const char* expectedType = propSchema["type"].as<const char*>();
159+
if (expectedType && !validateType(value, expectedType)) {
160+
String msg = "must be ";
161+
msg += expectedType;
162+
msg += ", got ";
163+
msg += jsonTypeName(value);
164+
result.addError(fullPath, msg);
165+
continue; // Skip further checks on wrong type
166+
}
167+
}
168+
169+
// Enum check (string values)
170+
if (propSchema.containsKey("enum")) {
171+
JsonArray enumValues = propSchema["enum"].as<JsonArray>();
172+
bool found = false;
173+
for (JsonVariant ev : enumValues) {
174+
if (value.is<const char*>() && ev.is<const char*>()) {
175+
if (strcmp(value.as<const char*>(), ev.as<const char*>()) == 0) {
176+
found = true;
177+
break;
178+
}
179+
} else if (value.is<int>() && ev.is<int>()) {
180+
if (value.as<int>() == ev.as<int>()) {
181+
found = true;
182+
break;
183+
}
184+
} else if (value.is<double>() && ev.is<double>()) {
185+
if (value.as<double>() == ev.as<double>()) {
186+
found = true;
187+
break;
188+
}
189+
} else if (value.is<bool>() && ev.is<bool>()) {
190+
if (value.as<bool>() == ev.as<bool>()) {
191+
found = true;
192+
break;
193+
}
194+
}
195+
}
196+
if (!found) {
197+
String msg = "must be one of [";
198+
bool first = true;
199+
for (JsonVariant ev : enumValues) {
200+
if (!first) msg += ", ";
201+
if (ev.is<const char*>()) {
202+
msg += "\"";
203+
msg += ev.as<const char*>();
204+
msg += "\"";
205+
} else if (ev.is<int>() || ev.is<long>()) {
206+
msg += String(ev.as<long>());
207+
} else if (ev.is<double>() || ev.is<float>()) {
208+
msg += String(ev.as<double>(), 2);
209+
} else if (ev.is<bool>()) {
210+
msg += ev.as<bool>() ? "true" : "false";
211+
} else {
212+
msg += "?";
213+
}
214+
first = false;
215+
}
216+
msg += "]";
217+
result.addError(fullPath, msg);
218+
}
219+
}
220+
221+
// Numeric range: minimum/maximum
222+
if (propSchema.containsKey("minimum") && (value.is<int>() || value.is<long>() || value.is<float>() || value.is<double>())) {
223+
double min = propSchema["minimum"].as<double>();
224+
if (value.as<double>() < min) {
225+
result.addError(fullPath, "must be >= " + String(min, 2));
226+
}
227+
}
228+
if (propSchema.containsKey("maximum") && (value.is<int>() || value.is<long>() || value.is<float>() || value.is<double>())) {
229+
double max = propSchema["maximum"].as<double>();
230+
if (value.as<double>() > max) {
231+
result.addError(fullPath, "must be <= " + String(max, 2));
232+
}
233+
}
234+
235+
// String length: minLength/maxLength
236+
if (value.is<const char*>()) {
237+
size_t len = strlen(value.as<const char*>());
238+
if (propSchema.containsKey("minLength")) {
239+
size_t minLen = propSchema["minLength"].as<size_t>();
240+
if (len < minLen) {
241+
result.addError(fullPath, "length must be >= " + String((unsigned long)minLen));
242+
}
243+
}
244+
if (propSchema.containsKey("maxLength")) {
245+
size_t maxLen = propSchema["maxLength"].as<size_t>();
246+
if (len > maxLen) {
247+
result.addError(fullPath, "length must be <= " + String((unsigned long)maxLen));
248+
}
249+
}
250+
}
251+
252+
// Array length: minItems/maxItems
253+
if (value.is<JsonArray>()) {
254+
size_t len = value.as<JsonArray>().size();
255+
if (propSchema.containsKey("minItems")) {
256+
size_t minItems = propSchema["minItems"].as<size_t>();
257+
if (len < minItems) {
258+
result.addError(fullPath, "must have >= " + String((unsigned long)minItems) + " items");
259+
}
260+
}
261+
if (propSchema.containsKey("maxItems")) {
262+
size_t maxItems = propSchema["maxItems"].as<size_t>();
263+
if (len > maxItems) {
264+
result.addError(fullPath, "must have <= " + String((unsigned long)maxItems) + " items");
265+
}
266+
}
267+
}
268+
269+
// Recursive validation for nested objects
270+
if (value.is<JsonObject>() && propSchema.containsKey("properties")) {
271+
ValidationResult nested = validateArguments(
272+
value.as<JsonObject>(), propSchema, fullPath);
273+
if (!nested.valid) {
274+
for (const auto& err : nested.errors) {
275+
result.addError(err.field, err.message);
276+
}
277+
}
278+
}
279+
}
280+
}
281+
282+
return result;
283+
}
284+
285+
} // namespace mcpd
286+
287+
#endif // MCPD_VALIDATION_H

src/mcpd.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,19 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
778778
if (tool.name == toolName) {
779779
JsonObject arguments = params["arguments"].as<JsonObject>();
780780

781+
// Input validation against declared schema
782+
if (_inputValidation && !tool.inputSchemaJson.isEmpty()) {
783+
JsonDocument schemaDoc;
784+
DeserializationError schemaErr = deserializeJson(schemaDoc, tool.inputSchemaJson);
785+
if (!schemaErr && schemaDoc.is<JsonObject>()) {
786+
ValidationResult vr = validateArguments(arguments, schemaDoc.as<JsonObject>());
787+
if (!vr.valid) {
788+
if (!requestId.isEmpty()) _requestTracker.completeRequest(requestId);
789+
return _jsonRpcError(id, -32602, vr.toString().c_str());
790+
}
791+
}
792+
}
793+
781794
// Handle task-augmented request
782795
if (isTaskRequest) {
783796
// Check tool-level task support

0 commit comments

Comments
 (0)