Skip to content

Commit 3f0b5dc

Browse files
author
Nicola Spieser
committed
feat: tool call hooks, structured error data, fix param shadowing — bump to v0.29.1
- Add onBeforeToolCall/onAfterToolCall middleware hooks with ToolCallContext (toolName, args, timing, error state). Before-hook can reject calls. - Add jsonRpcErrorWithData() for JSON-RPC errors with structured data field - Fix parameter shadowing in _handleResourcesRead (params → templateVars) - Fix 4 version-mismatch test failures (0.28.0 → 0.29.1) - Add 11 new tests (hooks: 8, error data: 3) - Total: 1028 tests, all passing
1 parent 75e10d0 commit 3f0b5dc

7 files changed

Lines changed: 299 additions & 12 deletions

File tree

CHANGELOG.md

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

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

5+
## [0.29.1] - 2026-02-21
6+
7+
### Features
8+
- **Tool call hooks**`onBeforeToolCall()` and `onAfterToolCall()` middleware for logging, access control, metrics, and auditing. Before-hook can reject calls; after-hook receives timing and error info via `ToolCallContext`.
9+
- **Structured JSON-RPC error data**`jsonRpcErrorWithData()` public API for returning errors with an optional `data` field per JSON-RPC spec.
10+
11+
### Fixes
12+
- **Fix parameter shadowing** in `_handleResourcesRead()` — local `params` variable renamed to `templateVars` to avoid shadowing the function parameter (could cause issues on strict compilers).
13+
- **Fix version mismatch in tests** — 4 tests were checking for "0.28.0" instead of the current version.
14+
15+
### Tests
16+
- Added **11 new tests** for tool call hooks (8) and structured error data (3):
17+
- Before-hook allow/reject, after-hook context/error detection, hook ordering, rejection skips after-hook, argument forwarding
18+
- Error-with-data serialization, empty data, ID preservation
19+
- **Total: 1028 tests** (1017 → 1028)
20+
521
## [0.29.0] - 2026-02-21
622

723
### Features

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-03-26 | ❌ custom WS ||
36-
| Actually compiles |1017 tests | ❌ self-described | N/A |
36+
| Actually compiles |1028 tests | ❌ self-described | N/A |
3737
| Streamable HTTP + SSE ||||
3838
| WebSocket transport ||||
3939
| Claude Desktop bridge ||||

src/mcpd.cpp

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,24 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
703703
if (tool.name == toolName) {
704704
JsonObject arguments = params["arguments"].as<JsonObject>();
705705

706+
// Before-call hook: allow rejection
707+
if (_beforeToolCallHook) {
708+
ToolCallContext ctx;
709+
ctx.toolName = toolName;
710+
ctx.args = &arguments;
711+
ctx.startMs = millis();
712+
ctx.durationMs = 0;
713+
ctx.isError = false;
714+
if (!_beforeToolCallHook(ctx)) {
715+
if (!requestId.isEmpty()) {
716+
_requestTracker.completeRequest(requestId);
717+
}
718+
return _jsonRpcError(id, -32600, "Tool call rejected");
719+
}
720+
}
721+
722+
unsigned long callStartMs = millis();
723+
706724
// Check if this tool has a rich handler
707725
MCPRichToolHandler richHandler = nullptr;
708726
for (const auto& rh : _richTools) {
@@ -713,6 +731,7 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
713731
}
714732

715733
String resultStr;
734+
bool callIsError = false;
716735

717736
if (richHandler) {
718737
// Use rich handler for structured content
@@ -721,34 +740,46 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
721740
toolResult = richHandler(arguments);
722741
} catch (...) {
723742
toolResult = MCPToolResult::error("Internal tool error");
743+
callIsError = true;
724744
}
725745

726746
JsonDocument result;
727747
JsonObject resultObj = result.to<JsonObject>();
728748
toolResult.toJson(resultObj);
729749
serializeJson(result, resultStr);
750+
if (toolResult.isError) callIsError = true;
730751
} else {
731752
// Use simple handler (backward compatible)
732753
String handlerResult;
733-
bool isError = false;
734754
try {
735755
handlerResult = tool.handler(arguments);
736756
} catch (...) {
737757
handlerResult = "Internal tool error";
738-
isError = true;
758+
callIsError = true;
739759
}
740760

741761
JsonDocument result;
742762
JsonArray content = result["content"].to<JsonArray>();
743763
JsonObject textContent = content.add<JsonObject>();
744764
textContent["type"] = "text";
745765
textContent["text"] = handlerResult;
746-
if (isError) {
766+
if (callIsError) {
747767
result["isError"] = true;
748768
}
749769
serializeJson(result, resultStr);
750770
}
751771

772+
// After-call hook: logging/metrics
773+
if (_afterToolCallHook) {
774+
ToolCallContext ctx;
775+
ctx.toolName = toolName;
776+
ctx.args = &arguments;
777+
ctx.startMs = callStartMs;
778+
ctx.durationMs = millis() - callStartMs;
779+
ctx.isError = callIsError;
780+
_afterToolCallHook(ctx);
781+
}
782+
752783
// Complete request tracking
753784
if (!requestId.isEmpty()) {
754785
_requestTracker.completeRequest(requestId);
@@ -816,9 +847,9 @@ String Server::_handleResourcesRead(JsonVariant params, JsonVariant id) {
816847

817848
// Try matching against resource templates
818849
for (const auto& tmpl : _resourceTemplates) {
819-
std::map<String, String> params;
820-
if (tmpl.match(String(uri), params)) {
821-
String content = tmpl.handler(params);
850+
std::map<String, String> templateVars;
851+
if (tmpl.match(String(uri), templateVars)) {
852+
String content = tmpl.handler(templateVars);
822853

823854
JsonDocument result;
824855
JsonArray contents = result["contents"].to<JsonArray>();
@@ -1114,6 +1145,30 @@ String Server::_jsonRpcResult(JsonVariant id, const String& resultJson) {
11141145
return output;
11151146
}
11161147

1148+
String Server::jsonRpcErrorWithData(JsonVariant id, int code, const char* message,
1149+
const String& dataJson) {
1150+
JsonDocument doc;
1151+
doc["jsonrpc"] = "2.0";
1152+
if (!id.isNull()) {
1153+
doc["id"] = id;
1154+
} else {
1155+
doc["id"] = nullptr;
1156+
}
1157+
JsonObject error = doc["error"].to<JsonObject>();
1158+
error["code"] = code;
1159+
error["message"] = message;
1160+
1161+
if (!dataJson.isEmpty()) {
1162+
JsonDocument dataDoc;
1163+
deserializeJson(dataDoc, dataJson);
1164+
error["data"] = dataDoc.as<JsonVariant>();
1165+
}
1166+
1167+
String output;
1168+
serializeJson(doc, output);
1169+
return output;
1170+
}
1171+
11171172
String Server::_jsonRpcError(JsonVariant id, int code, const char* message) {
11181173
JsonDocument doc;
11191174
doc["jsonrpc"] = "2.0";

src/mcpd.h

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
#include "MCPTransportBLE.h"
4545
#endif
4646

47-
#define MCPD_VERSION "0.29.0"
47+
#define MCPD_VERSION "0.29.1"
4848
#define MCPD_MCP_PROTOCOL_VERSION "2025-03-26"
4949

5050
namespace mcpd {
@@ -263,6 +263,46 @@ class Server {
263263
/** Access the Prometheus metrics module */
264264
Metrics& metrics() { return _metrics; }
265265

266+
// ── Tool Call Hooks ─────────────────────────────────────────────────
267+
268+
/**
269+
* Context passed to tool call hooks.
270+
*/
271+
struct ToolCallContext {
272+
String toolName; // Name of the tool being called
273+
const JsonObject* args; // Tool arguments (read-only in before hook)
274+
unsigned long startMs; // millis() when call started (after hook only)
275+
unsigned long durationMs; // Call duration in ms (after hook only)
276+
bool isError; // Whether the tool returned an error (after hook only)
277+
};
278+
279+
/**
280+
* Before-call hook. Return true to proceed, false to reject the call.
281+
* When rejected, the server returns a "Tool call rejected" error.
282+
*/
283+
using BeforeToolCallHook = std::function<bool(const ToolCallContext& ctx)>;
284+
285+
/**
286+
* After-call hook. Called after tool execution completes.
287+
* Useful for logging, metrics, auditing.
288+
*/
289+
using AfterToolCallHook = std::function<void(const ToolCallContext& ctx)>;
290+
291+
/** Set a hook called before every tool call. Return false to reject. */
292+
void onBeforeToolCall(BeforeToolCallHook hook) { _beforeToolCallHook = hook; }
293+
294+
/** Set a hook called after every tool call completes. */
295+
void onAfterToolCall(AfterToolCallHook hook) { _afterToolCallHook = hook; }
296+
297+
// ── JSON-RPC Error Data ────────────────────────────────────────────
298+
299+
/**
300+
* Create a JSON-RPC error response with optional structured data.
301+
* Per JSON-RPC spec, errors may include a "data" field with additional info.
302+
*/
303+
String jsonRpcErrorWithData(JsonVariant id, int code, const char* message,
304+
const String& dataJson);
305+
266306
// ── Lifecycle Hooks ────────────────────────────────────────────────
267307

268308
using LifecycleCallback = std::function<void()>;
@@ -369,6 +409,10 @@ class Server {
369409
LifecycleCallback _onConnectCb;
370410
LifecycleCallback _onDisconnectCb;
371411

412+
// Tool call hooks
413+
BeforeToolCallHook _beforeToolCallHook;
414+
AfterToolCallHook _afterToolCallHook;
415+
372416
std::vector<MCPTool> _tools;
373417
std::vector<std::pair<String, MCPRichToolHandler>> _richTools; // name → handler
374418
std::vector<MCPResource> _resources;

test/test_infrastructure.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ TEST(completion_manager_no_providers) {
719719
// ═══════════════════════════════════════════════════════════════════════
720720

721721
TEST(version_constants) {
722-
ASSERT_EQ(String(MCPD_VERSION), String("0.28.0"));
722+
ASSERT_EQ(String(MCPD_VERSION), String("0.29.1"));
723723
ASSERT_EQ(String(MCPD_MCP_PROTOCOL_VERSION), String("2025-03-26"));
724724
}
725725

0 commit comments

Comments
 (0)