Skip to content

Commit fb8f850

Browse files
committed
feat: role-based access control (MCPAccessControl.h, RBAC for tools, key-to-role mapping, server integration) — 36 new tests — bump to v0.42.0
1 parent 6f769d3 commit fb8f850

12 files changed

Lines changed: 700 additions & 11 deletions

CHANGELOG.md

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

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

5+
## [0.42.0] — 2026-02-24
6+
7+
### Added
8+
- **Access Control / RBAC** (`MCPAccessControl.h`): Role-based access control for tools. Associates API keys with roles, restricts tool access by role. Designed for MCU servers exposed on networks where you want to limit which clients can call destructive tools.
9+
- `AccessControl`: Enable/disable RBAC, define roles, map API keys to roles, restrict tools to specific roles.
10+
- `addRole()`, `removeRole()`, `hasRole()`, `roles()`: Role lifecycle management.
11+
- `mapKeyToRole()`, `unmapKey()`, `roleForKey()`: Key-to-role associations.
12+
- `restrictTool()`, `unrestrictTool()`, `isToolRestricted()`, `toolAllowedRoles()`: Per-tool access control.
13+
- `canAccess(toolName, apiKey)`: Central access check — unrestricted tools always pass, restricted tools require matching role.
14+
- `setDefaultRole()`: Configurable fallback role for unauthenticated/unmapped callers (default: "guest").
15+
- `restrictDestructiveTools()`: Bulk-restrict multiple tools to admin roles.
16+
- `toolsForRole()`: Query which tools a given role can access.
17+
- `toJSON()`, `statsJSON()`: Serialization for debugging and monitoring.
18+
- Server integration: `server.accessControl()` accessor, RBAC check in tool dispatch pipeline (before tool execution).
19+
- Pairs with existing `MCPAuth.h` — auth identifies the caller, RBAC controls what they can do.
20+
- **36 new tests**: Full coverage of role management, key mapping, tool restrictions, access checks, edge cases, server integration. **Total: 1493 tests**.
21+
22+
### Fixed
23+
- Version sync: all test files now reference correct MCPD_VERSION.
24+
525
## [0.41.0] — 2026-02-23
626

727
### Added

README.md

Lines changed: 2 additions & 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 |1457 tests | ❌ self-described | N/A |
36+
| Actually compiles |1493 tests | ❌ self-described | N/A |
3737
| Streamable HTTP + SSE ||||
3838
| WebSocket transport ||||
3939
| Claude Desktop bridge ||||
@@ -53,6 +53,7 @@
5353
| Request Cancellation ||||
5454
| Prompts support ||||
5555
| Authentication ||||
56+
| Role-Based Access Control (RBAC) ||||
5657
| OTA Updates ||||
5758
| Prometheus Metrics ||||
5859
| Captive Portal + Setup CLI ||||

library.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcpd",
3-
"version": "0.41.0",
3+
"version": "0.42.0",
44
"description": "MCP Server SDK for Microcontrollers — Expose ESP32/RP2040/STM32 hardware as AI-accessible tools via Model Context Protocol",
55
"keywords": [
66
"mcp",

library.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name=mcpd
2-
version=0.41.0
2+
version=0.42.0
33
author=Nicola Spieser
44
maintainer=Nicola Spieser <redbasecap-buiss@users.noreply.github.com>
55
sentence=MCP Server SDK for Microcontrollers

src/MCPAccessControl.h

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* mcpd — Role-Based Access Control (RBAC) for tools
3+
*
4+
* Associates API keys with roles, and restricts tool access by role.
5+
* When enabled, tool calls are checked against the caller's role.
6+
* Unauthenticated callers get the "guest" role (configurable).
7+
*
8+
* Usage:
9+
* auto& ac = server.accessControl();
10+
* ac.addRole("admin");
11+
* ac.addRole("viewer");
12+
* ac.mapKeyToRole("secret-admin-key", "admin");
13+
* ac.mapKeyToRole("read-only-key", "viewer");
14+
* ac.restrictTool("gpio_write", {"admin"});
15+
* ac.restrictTool("gpio_read", {"admin", "viewer"});
16+
* // Tools without restrictions are accessible to all roles
17+
*/
18+
19+
#ifndef MCPD_ACCESS_CONTROL_H
20+
#define MCPD_ACCESS_CONTROL_H
21+
22+
#include <Arduino.h>
23+
#include <functional>
24+
#include <map>
25+
#include <set>
26+
#include <vector>
27+
#include <string>
28+
29+
namespace mcpd {
30+
31+
class AccessControl {
32+
public:
33+
AccessControl() = default;
34+
35+
// ── Role Management ──────────────────────────────────────────────
36+
37+
/** Add a role definition. */
38+
void addRole(const char* role) {
39+
_roles.insert(String(role));
40+
}
41+
42+
/** Remove a role definition and all its associations. */
43+
void removeRole(const char* role) {
44+
String r(role);
45+
_roles.erase(r);
46+
// Remove key→role mappings for this role
47+
for (auto it = _keyToRole.begin(); it != _keyToRole.end(); ) {
48+
if (it->second == r) {
49+
it = _keyToRole.erase(it);
50+
} else {
51+
++it;
52+
}
53+
}
54+
// Remove from tool restrictions
55+
for (auto& pair : _toolRoles) {
56+
pair.second.erase(r);
57+
}
58+
}
59+
60+
/** Check if a role exists. */
61+
bool hasRole(const char* role) const {
62+
return _roles.count(String(role)) > 0;
63+
}
64+
65+
/** Get all defined roles. */
66+
std::set<String> roles() const { return _roles; }
67+
68+
// ── Key-to-Role Mapping ──────────────────────────────────────────
69+
70+
/** Map an API key to a role. A key can only have one role. */
71+
void mapKeyToRole(const char* apiKey, const char* role) {
72+
_keyToRole[String(apiKey)] = String(role);
73+
_roles.insert(String(role)); // Auto-add role if not yet defined
74+
}
75+
76+
/** Remove a key mapping. */
77+
void unmapKey(const char* apiKey) {
78+
_keyToRole.erase(String(apiKey));
79+
}
80+
81+
/** Get the role for a key, or empty string if not mapped. */
82+
String roleForKey(const char* apiKey) const {
83+
auto it = _keyToRole.find(String(apiKey));
84+
if (it != _keyToRole.end()) return it->second;
85+
return String();
86+
}
87+
88+
// ── Tool Restrictions ────────────────────────────────────────────
89+
90+
/**
91+
* Restrict a tool to specific roles.
92+
* Only callers with one of the listed roles can call this tool.
93+
* An empty set means the tool is restricted to no one (effectively disabled).
94+
*/
95+
void restrictTool(const char* toolName, const std::vector<const char*>& allowedRoles) {
96+
std::set<String> roleSet;
97+
for (auto r : allowedRoles) roleSet.insert(String(r));
98+
_toolRoles[String(toolName)] = roleSet;
99+
}
100+
101+
/** Overload accepting String set. */
102+
void restrictToolSet(const char* toolName, const std::set<String>& allowedRoles) {
103+
_toolRoles[String(toolName)] = allowedRoles;
104+
}
105+
106+
/** Remove restrictions from a tool (makes it accessible to all). */
107+
void unrestrictTool(const char* toolName) {
108+
_toolRoles.erase(String(toolName));
109+
}
110+
111+
/** Check if a tool has restrictions. */
112+
bool isToolRestricted(const char* toolName) const {
113+
return _toolRoles.count(String(toolName)) > 0;
114+
}
115+
116+
/** Get allowed roles for a tool (empty set = unrestricted). */
117+
std::set<String> toolAllowedRoles(const char* toolName) const {
118+
auto it = _toolRoles.find(String(toolName));
119+
if (it != _toolRoles.end()) return it->second;
120+
return {};
121+
}
122+
123+
// ── Access Checks ────────────────────────────────────────────────
124+
125+
/** Set the default role for unauthenticated/unmapped callers. */
126+
void setDefaultRole(const char* role) {
127+
_defaultRole = String(role);
128+
_roles.insert(String(role));
129+
}
130+
131+
/** Get the default role. */
132+
String defaultRole() const { return _defaultRole; }
133+
134+
/**
135+
* Check if a caller with the given API key can access a tool.
136+
* - If the tool has no restrictions, returns true.
137+
* - If the tool is restricted, checks the key's role (or default role).
138+
* - If RBAC is not enabled, always returns true.
139+
*/
140+
bool canAccess(const char* toolName, const char* apiKey = nullptr) const {
141+
if (!_enabled) return true;
142+
143+
// Tool not restricted → allow
144+
auto toolIt = _toolRoles.find(String(toolName));
145+
if (toolIt == _toolRoles.end()) return true;
146+
147+
// Determine caller's role
148+
String callerRole = _defaultRole;
149+
if (apiKey && apiKey[0] != '\0') {
150+
auto keyIt = _keyToRole.find(String(apiKey));
151+
if (keyIt != _keyToRole.end()) {
152+
callerRole = keyIt->second;
153+
}
154+
}
155+
156+
// Check if caller's role is in allowed set
157+
if (callerRole.isEmpty()) return false;
158+
return toolIt->second.count(callerRole) > 0;
159+
}
160+
161+
/** Enable/disable RBAC checking. When disabled, canAccess always returns true. */
162+
void enable(bool v = true) { _enabled = v; }
163+
void disable() { _enabled = false; }
164+
bool isEnabled() const { return _enabled; }
165+
166+
// ── Bulk Operations ──────────────────────────────────────────────
167+
168+
/**
169+
* Restrict all tools with a given annotation to specific roles.
170+
* Useful for restricting all destructive tools to admin only.
171+
*/
172+
void restrictDestructiveTools(const std::vector<const char*>& toolNames,
173+
const std::vector<const char*>& allowedRoles) {
174+
for (auto name : toolNames) {
175+
restrictTool(name, allowedRoles);
176+
}
177+
}
178+
179+
/**
180+
* Get the list of tools accessible to a given role.
181+
* Returns tool names that are either unrestricted or explicitly allow this role.
182+
*/
183+
std::vector<String> toolsForRole(const char* role, const std::vector<String>& allTools) const {
184+
String r(role);
185+
std::vector<String> result;
186+
for (const auto& tool : allTools) {
187+
auto it = _toolRoles.find(tool);
188+
if (it == _toolRoles.end()) {
189+
// Unrestricted
190+
result.push_back(tool);
191+
} else if (it->second.count(r) > 0) {
192+
result.push_back(tool);
193+
}
194+
}
195+
return result;
196+
}
197+
198+
// ── JSON Serialization ───────────────────────────────────────────
199+
200+
/** Serialize RBAC config to JSON string. */
201+
String toJSON() const {
202+
String json = "{\"enabled\":";
203+
json += _enabled ? "true" : "false";
204+
json += ",\"defaultRole\":\"";
205+
json += _defaultRole;
206+
json += "\",\"roles\":[";
207+
bool first = true;
208+
for (const auto& r : _roles) {
209+
if (!first) json += ",";
210+
json += "\"" + r + "\"";
211+
first = false;
212+
}
213+
json += "],\"toolRestrictions\":{";
214+
first = true;
215+
for (const auto& pair : _toolRoles) {
216+
if (!first) json += ",";
217+
json += "\"" + pair.first + "\":[";
218+
bool f2 = true;
219+
for (const auto& r : pair.second) {
220+
if (!f2) json += ",";
221+
json += "\"" + r + "\"";
222+
f2 = false;
223+
}
224+
json += "]";
225+
first = false;
226+
}
227+
json += "},\"keyMappings\":";
228+
json += String((int)_keyToRole.size());
229+
json += "}";
230+
return json;
231+
}
232+
233+
/** Get stats as JSON. */
234+
String statsJSON() const {
235+
String json = "{\"enabled\":";
236+
json += _enabled ? "true" : "false";
237+
json += ",\"roles\":";
238+
json += String((int)_roles.size());
239+
json += ",\"keyMappings\":";
240+
json += String((int)_keyToRole.size());
241+
json += ",\"restrictedTools\":";
242+
json += String((int)_toolRoles.size());
243+
json += "}";
244+
return json;
245+
}
246+
247+
private:
248+
bool _enabled = false;
249+
String _defaultRole = "guest";
250+
std::set<String> _roles;
251+
std::map<String, String> _keyToRole; // API key → role
252+
std::map<String, std::set<String>> _toolRoles; // tool name → allowed roles
253+
};
254+
255+
} // namespace mcpd
256+
257+
#endif // MCPD_ACCESS_CONTROL_H

src/mcpd.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,19 @@ String Server::_handleToolsCall(JsonVariant params, JsonVariant id) {
781781
return _jsonRpcError(id, -32602, "Tool not found");
782782
}
783783

784+
// RBAC check: verify caller has permission for this tool
785+
if (_accessControl.isEnabled()) {
786+
// Extract API key from _meta.apiKey (set by auth layer) or use empty
787+
const char* callerKey = nullptr;
788+
if (!params["_meta"].isNull() && !params["_meta"]["apiKey"].isNull()) {
789+
callerKey = params["_meta"]["apiKey"].as<const char*>();
790+
}
791+
if (!_accessControl.canAccess(toolName, callerKey)) {
792+
if (!requestId.isEmpty()) _requestTracker.completeRequest(requestId);
793+
return _jsonRpcError(id, -32603, "Access denied: insufficient permissions");
794+
}
795+
}
796+
784797
// Find the tool
785798
for (const auto& tool : _tools) {
786799
if (tool.name == toolName) {

src/mcpd.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,13 @@
4747
#include "MCPScheduler.h"
4848
#include "MCPEventStore.h"
4949
#include "MCPStateStore.h"
50+
#include "MCPAccessControl.h"
5051

5152
#ifdef ESP32
5253
#include "MCPTransportBLE.h"
5354
#endif
5455

55-
#define MCPD_VERSION "0.41.0"
56+
#define MCPD_VERSION "0.42.0"
5657
#define MCPD_MCP_PROTOCOL_VERSION "2025-11-25"
5758
#define MCPD_MCP_PROTOCOL_VERSION_COMPAT "2025-03-26"
5859

@@ -314,6 +315,10 @@ class Server {
314315
/** Access the rate limiter for stats or manual control */
315316
RateLimiter& rateLimiter() { return _rateLimiter; }
316317

318+
// ── Access Control (RBAC) ──────────────────────────────────────────
319+
/** Access the RBAC controller for role-based tool restrictions. */
320+
AccessControl& accessControl() { return _accessControl; }
321+
317322
// ── Session Management ─────────────────────────────────────────────
318323

319324
/** Access session manager for multi-client session control */
@@ -563,6 +568,7 @@ class Server {
563568
#endif
564569

565570
RateLimiter _rateLimiter;
571+
AccessControl _accessControl;
566572
SessionManager _sessionManager;
567573
HeapMonitor _heapMonitor;
568574
Auth _auth;

test/native/Makefile

Lines changed: 4 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 test_toolgroups test_eventstore test_statestore
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 test_eventstore test_statestore test_accesscontrol
77

88
.PHONY: all clean test
99

@@ -103,3 +103,6 @@ test: $(TESTS)
103103

104104
clean:
105105
rm -f $(TESTS)
106+
107+
test_accesscontrol: ../test_accesscontrol.cpp ../arduino_mock.h ../test_framework.h ../../src/MCPAccessControl.h ../../src/mcpd.h ../../src/mcpd.cpp
108+
$(CXX) $(CXXFLAGS) $(INCLUDES) -o $@ ../test_accesscontrol.cpp

0 commit comments

Comments
 (0)