Skip to content

Commit 82877e8

Browse files
author
Nicola Spieser
committed
feat: tool groups (MCPToolGroup.h, named groups, bulk enable/disable, multi-group membership, reverse index, JSON serialization) — 45 new tests — bump to v0.39.0
1 parent add5048 commit 82877e8

9 files changed

Lines changed: 787 additions & 14 deletions

File tree

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 |1287 tests | ❌ self-described | N/A |
36+
| Actually compiles |1332 tests | ❌ self-described | N/A |
3737
| Streamable HTTP + SSE ||||
3838
| WebSocket transport ||||
3939
| Claude Desktop bridge ||||

src/MCPToolGroup.h

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* mcpd — Tool Groups
3+
*
4+
* Organize tools into named groups for bulk enable/disable and logical grouping.
5+
* Useful for MCU projects with many tools (e.g., "sensors", "actuators", "network").
6+
*/
7+
8+
#ifndef MCPD_TOOL_GROUP_H
9+
#define MCPD_TOOL_GROUP_H
10+
11+
#include <Arduino.h>
12+
#include <functional>
13+
#include <map>
14+
#include <set>
15+
#include <vector>
16+
17+
namespace mcpd {
18+
19+
/**
20+
* Represents a named group of tools.
21+
*/
22+
struct ToolGroup {
23+
String name; // Group identifier (e.g., "sensors")
24+
String description; // Human-readable description
25+
bool enabled = true; // Group-level enable/disable
26+
std::set<String> tools; // Tool names in this group
27+
28+
ToolGroup() = default;
29+
ToolGroup(const char* name, const char* desc = "")
30+
: name(name), description(desc) {}
31+
};
32+
33+
/**
34+
* Manages tool groups — logical groupings for bulk operations.
35+
*
36+
* Tools can belong to multiple groups. Disabling a group disables
37+
* all its tools (unless they belong to another enabled group).
38+
*
39+
* Usage:
40+
* manager.createGroup("sensors", "All sensor tools");
41+
* manager.addToolToGroup("temperature_read", "sensors");
42+
* manager.addToolToGroup("humidity_read", "sensors");
43+
* manager.disableGroup("sensors"); // disables both tools
44+
*/
45+
class ToolGroupManager {
46+
public:
47+
/**
48+
* Create a new tool group.
49+
* @return true if created, false if already exists
50+
*/
51+
bool createGroup(const char* name, const char* description = "") {
52+
String key(name);
53+
if (_groups.count(key)) return false;
54+
_groups[key] = ToolGroup(name, description);
55+
return true;
56+
}
57+
58+
/**
59+
* Remove a tool group.
60+
* @return true if removed, false if not found
61+
*/
62+
bool removeGroup(const char* name) {
63+
String key(name);
64+
auto it = _groups.find(key);
65+
if (it == _groups.end()) return false;
66+
// Remove reverse mappings
67+
for (auto& tool : it->second.tools) {
68+
auto tit = _toolToGroups.find(tool);
69+
if (tit != _toolToGroups.end()) {
70+
tit->second.erase(key);
71+
if (tit->second.empty()) _toolToGroups.erase(tit);
72+
}
73+
}
74+
_groups.erase(it);
75+
return true;
76+
}
77+
78+
/**
79+
* Add a tool to a group.
80+
* Creates the group if it doesn't exist.
81+
* @return true if the tool was added (not already in group)
82+
*/
83+
bool addToolToGroup(const char* toolName, const char* groupName) {
84+
String gKey(groupName);
85+
String tKey(toolName);
86+
if (!_groups.count(gKey)) {
87+
createGroup(groupName);
88+
}
89+
bool inserted = _groups[gKey].tools.insert(tKey).second;
90+
if (inserted) {
91+
_toolToGroups[tKey].insert(gKey);
92+
}
93+
return inserted;
94+
}
95+
96+
/**
97+
* Remove a tool from a group.
98+
* @return true if removed
99+
*/
100+
bool removeToolFromGroup(const char* toolName, const char* groupName) {
101+
String gKey(groupName);
102+
String tKey(toolName);
103+
auto it = _groups.find(gKey);
104+
if (it == _groups.end()) return false;
105+
bool erased = it->second.tools.erase(tKey) > 0;
106+
if (erased) {
107+
auto tit = _toolToGroups.find(tKey);
108+
if (tit != _toolToGroups.end()) {
109+
tit->second.erase(gKey);
110+
if (tit->second.empty()) _toolToGroups.erase(tit);
111+
}
112+
}
113+
return erased;
114+
}
115+
116+
/**
117+
* Enable or disable a group.
118+
* @return true if group exists
119+
*/
120+
bool enableGroup(const char* name, bool enabled = true) {
121+
String key(name);
122+
auto it = _groups.find(key);
123+
if (it == _groups.end()) return false;
124+
it->second.enabled = enabled;
125+
return true;
126+
}
127+
128+
bool disableGroup(const char* name) { return enableGroup(name, false); }
129+
130+
/**
131+
* Check if a group is enabled.
132+
*/
133+
bool isGroupEnabled(const char* name) const {
134+
String key(name);
135+
auto it = _groups.find(key);
136+
if (it == _groups.end()) return false;
137+
return it->second.enabled;
138+
}
139+
140+
/**
141+
* Check if a group exists.
142+
*/
143+
bool hasGroup(const char* name) const {
144+
return _groups.count(String(name)) > 0;
145+
}
146+
147+
/**
148+
* Check if a tool is disabled by group membership.
149+
* A tool is group-disabled if ALL its groups are disabled.
150+
* Tools with no group are never group-disabled.
151+
*/
152+
bool isToolGroupDisabled(const char* toolName) const {
153+
String tKey(toolName);
154+
auto it = _toolToGroups.find(tKey);
155+
if (it == _toolToGroups.end()) return false; // no group = not group-disabled
156+
// If tool is in at least one enabled group, it's not disabled
157+
for (auto& gName : it->second) {
158+
auto git = _groups.find(gName);
159+
if (git != _groups.end() && git->second.enabled) return false;
160+
}
161+
return true; // all groups disabled
162+
}
163+
164+
/**
165+
* Get all tools in a group.
166+
*/
167+
std::set<String> getToolsInGroup(const char* name) const {
168+
String key(name);
169+
auto it = _groups.find(key);
170+
if (it == _groups.end()) return {};
171+
return it->second.tools;
172+
}
173+
174+
/**
175+
* Get all groups a tool belongs to.
176+
*/
177+
std::set<String> getGroupsForTool(const char* toolName) const {
178+
String tKey(toolName);
179+
auto it = _toolToGroups.find(tKey);
180+
if (it == _toolToGroups.end()) return {};
181+
return it->second;
182+
}
183+
184+
/**
185+
* Get all group names.
186+
*/
187+
std::vector<String> getGroupNames() const {
188+
std::vector<String> names;
189+
names.reserve(_groups.size());
190+
for (auto& pair : _groups) {
191+
names.push_back(pair.first);
192+
}
193+
return names;
194+
}
195+
196+
/**
197+
* Get a group by name (const).
198+
* @return pointer to group, or nullptr if not found
199+
*/
200+
const ToolGroup* getGroup(const char* name) const {
201+
String key(name);
202+
auto it = _groups.find(key);
203+
if (it == _groups.end()) return nullptr;
204+
return &it->second;
205+
}
206+
207+
/**
208+
* Get total number of groups.
209+
*/
210+
size_t groupCount() const { return _groups.size(); }
211+
212+
/**
213+
* Serialize all groups to JSON.
214+
* Returns: [{"name":"sensors","description":"...","enabled":true,"tools":["a","b"]}, ...]
215+
*/
216+
String toJson() const {
217+
String json = "[";
218+
bool first = true;
219+
for (auto& pair : _groups) {
220+
if (!first) json += ",";
221+
first = false;
222+
json += "{\"name\":\"" + pair.second.name + "\"";
223+
if (pair.second.description.length() > 0) {
224+
json += ",\"description\":\"" + pair.second.description + "\"";
225+
}
226+
json += ",\"enabled\":";
227+
json += pair.second.enabled ? "true" : "false";
228+
json += ",\"tools\":[";
229+
bool tfirst = true;
230+
for (auto& t : pair.second.tools) {
231+
if (!tfirst) json += ",";
232+
tfirst = false;
233+
json += "\"" + t + "\"";
234+
}
235+
json += "]}";
236+
}
237+
json += "]";
238+
return json;
239+
}
240+
241+
private:
242+
std::map<String, ToolGroup> _groups;
243+
std::map<String, std::set<String>> _toolToGroups; // reverse index: tool → groups
244+
};
245+
246+
} // namespace mcpd
247+
248+
#endif // MCPD_TOOL_GROUP_H

src/mcpd.cpp

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,15 @@ bool Server::enableTool(const char* name, bool enabled) {
100100
}
101101

102102
bool Server::isToolEnabled(const char* name) const {
103-
return _disabledTools.find(String(name)) == _disabledTools.end();
103+
if (_disabledTools.find(String(name)) != _disabledTools.end()) return false;
104+
if (_toolGroups.isToolGroupDisabled(name)) return false;
105+
return true;
106+
}
107+
108+
bool Server::enableToolGroup(const char* name, bool enabled) {
109+
if (!_toolGroups.enableGroup(name, enabled)) return false;
110+
notifyToolsChanged();
111+
return true;
104112
}
105113

106114
bool Server::removeTool(const char* name) {
@@ -714,8 +722,8 @@ String Server::_handleToolsList(JsonVariant params, JsonVariant id) {
714722
}
715723

716724
for (size_t i = startIdx; i < endIdx; i++) {
717-
// Skip disabled tools
718-
if (_disabledTools.count(_tools[i].name)) continue;
725+
// Skip disabled tools (individually or by group)
726+
if (!isToolEnabled(_tools[i].name.c_str())) continue;
719727
JsonObject obj = tools.add<JsonObject>();
720728
_tools[i].toJson(obj);
721729

@@ -765,8 +773,8 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
765773
return _jsonRpcError(id, -32601, "Tasks not supported");
766774
}
767775

768-
// Reject disabled tools
769-
if (_disabledTools.count(String(toolName))) {
776+
// Reject disabled tools (individually or by group)
777+
if (!isToolEnabled(toolName)) {
770778
if (!requestId.isEmpty()) {
771779
_requestTracker.completeRequest(requestId);
772780
}

src/mcpd.h

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include "MCPLogging.h"
2929
#include "MCPCompletion.h"
3030
#include "MCPRoots.h"
31+
#include "MCPToolGroup.h"
3132
#include "MCPContent.h"
3233
#include "MCPProgress.h"
3334
#include "MCPTransport.h"
@@ -49,7 +50,7 @@
4950
#include "MCPTransportBLE.h"
5051
#endif
5152

52-
#define MCPD_VERSION "0.38.0"
53+
#define MCPD_VERSION "0.39.0"
5354
#define MCPD_MCP_PROTOCOL_VERSION "2025-11-25"
5455
#define MCPD_MCP_PROTOCOL_VERSION_COMPAT "2025-03-26"
5556

@@ -199,6 +200,36 @@ class Server {
199200
bool disableTool(const char* name) { return enableTool(name, false); }
200201
bool isToolEnabled(const char* name) const;
201202

203+
// ── Tool Groups ────────────────────────────────────────────────────
204+
205+
/** Access the tool group manager */
206+
ToolGroupManager& toolGroups() { return _toolGroups; }
207+
const ToolGroupManager& toolGroups() const { return _toolGroups; }
208+
209+
/**
210+
* Create a tool group and optionally assign tools.
211+
* Convenience wrapper around toolGroups().createGroup().
212+
*/
213+
bool createToolGroup(const char* name, const char* description = "") {
214+
return _toolGroups.createGroup(name, description);
215+
}
216+
217+
/**
218+
* Add a tool to a group (creates the group if needed).
219+
*/
220+
bool addToolToGroup(const char* toolName, const char* groupName) {
221+
return _toolGroups.addToolToGroup(toolName, groupName);
222+
}
223+
224+
/**
225+
* Enable or disable a tool group. Disabled groups hide their tools
226+
* from tools/list and prevent calls (unless the tool belongs to
227+
* another enabled group). Emits tools/list_changed notification.
228+
* @return true if group exists
229+
*/
230+
bool enableToolGroup(const char* name, bool enabled = true);
231+
bool disableToolGroup(const char* name) { return enableToolGroup(name, false); }
232+
202233
/** Get server name */
203234
const char* getName() const { return _name; }
204235

@@ -538,6 +569,7 @@ class Server {
538569
bool _inputValidation = false;
539570
bool _outputValidation = false;
540571
ToolResultCache _cache;
572+
ToolGroupManager _toolGroups;
541573
std::map<String, MCPTaskToolHandler> _taskToolHandlers;
542574
std::map<String, TaskSupport> _taskToolSupport;
543575

test/native/Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CXXFLAGS = -std=c++17 -Wall -Wextra -Wno-unused-parameter -DMCPD_TEST -pthread
33
INCLUDES = -I../../src -I../mock_includes -I..
44

55
# Targets
6-
TESTS = test_jsonrpc test_tools test_mcp_http test_infrastructure test_modules test_auth_platform test_robustness test_session test_content_transport test_advanced test_integration test_output_annotations test_2025_11_25 test_tasks test_validation test_cache test_scheduler test_pipeline
6+
TESTS = test_jsonrpc test_tools test_mcp_http test_infrastructure test_modules test_auth_platform test_robustness test_session test_content_transport test_advanced test_integration test_output_annotations test_2025_11_25 test_tasks test_validation test_cache test_scheduler test_pipeline test_toolgroups
77

88
.PHONY: all clean test
99

@@ -63,6 +63,9 @@ test_scheduler: ../test_scheduler.cpp ../arduino_mock.h ../test_framework.h ../.
6363
test_pipeline: ../test_pipeline.cpp ../arduino_mock.h ../test_framework.h ../../src/MCPPipeline.h
6464
$(CXX) $(CXXFLAGS) $(INCLUDES) -o $@ ../test_pipeline.cpp
6565

66+
test_toolgroups: ../test_toolgroups.cpp ../arduino_mock.h ../test_framework.h ../../src/mcpd.h ../../src/mcpd.cpp ../../src/MCPToolGroup.h
67+
$(CXX) $(CXXFLAGS) $(INCLUDES) -o $@ ../test_toolgroups.cpp
68+
6669
test: $(TESTS)
6770
@echo ""
6871
@echo "══════════════════════════════════════════"
@@ -87,6 +90,7 @@ test: $(TESTS)
8790
@./test_cache
8891
@./test_scheduler
8992
@./test_pipeline
93+
@./test_toolgroups
9094
@echo "All test suites completed."
9195

9296
clean:

0 commit comments

Comments
 (0)