Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ add_library(flapi-lib STATIC
src/sql_utils.cpp
src/mcp_server.cpp
src/mcp_tool_handler.cpp
src/mcp_tool_rate_limiter.cpp
src/mcp_route_handlers.cpp
src/mcp_session_manager.cpp
src/mcp_error_builder.cpp
Expand Down
7 changes: 7 additions & 0 deletions src/endpoint_config_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ void EndpointConfigParser::parseMcpToolFields(
}
}

// W2.5: per-tool rate limit. Absent block → enabled=false (no gate).
if (auto rl = mcp_tool_node["rate-limit"]; rl.IsDefined()) {
tool_info.rate_limit.enabled = config_manager_->safeGet<bool>(rl, "enabled", "mcp-tool.rate-limit.enabled", true);
tool_info.rate_limit.max = config_manager_->safeGet<int>(rl, "max", "mcp-tool.rate-limit.max", 100);
tool_info.rate_limit.interval = config_manager_->safeGet<int>(rl, "interval", "mcp-tool.rate-limit.interval", 60);
}

config.mcp_tool = tool_info;
}

Expand Down
5 changes: 5 additions & 0 deletions src/include/config_manager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ struct EndpointConfig {
std::vector<std::string> redact_columns;
bool sample = false;
} response;

// W2.5: per-tool rate limit. `enabled: false` (default) leaves
// the tool ungated; otherwise `max` calls are permitted per
// `interval` seconds, scoped to (tool_name, principal).
RateLimitConfig rate_limit;
};

struct MCPResourceInfo {
Expand Down
2 changes: 2 additions & 0 deletions src/include/mcp_tool_handler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "config_manager.hpp"
#include "database_manager.hpp"
#include "mcp_authorization_policy.hpp"
#include "mcp_tool_rate_limiter.hpp"
#include "sql_template_processor.hpp"
#include "request_validator.hpp"

Expand Down Expand Up @@ -82,6 +83,7 @@ QueryResult executeQueryWithEndpoint(const EndpointConfig& endpoint_config,
std::unique_ptr<SQLTemplateProcessor> sql_processor;
std::shared_ptr<AuditLogger> audit_logger;
MCPAuthorizationPolicy authorization_policy;
MCPToolRateLimiter rate_limiter;
};

} // namespace flapi
57 changes: 57 additions & 0 deletions src/include/mcp_tool_rate_limiter.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#pragma once

#include <chrono>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <unordered_map>

namespace flapi {

struct RateLimitConfig;

// W2.5: Per-tool rate limiter for MCP tool calls. A separate enforcement
// point from the REST `RateLimitMiddleware` because MCP tool calls all
// land on the same HTTP path (`/mcp/jsonrpc`) — keying on the URL path
// can't distinguish two tools. This limiter keys on (tool_name, principal)
// instead.
//
// Thread-safe (mutex-guarded); shared by all concurrent tool calls in
// the process. Disabled `RateLimitConfig` short-circuits to allow.
class MCPToolRateLimiter {
public:
struct AcquireDecision {
bool allowed = false;
std::int64_t remaining = 0;
std::int64_t retry_after_seconds = 0;
};

using Clock = std::function<std::chrono::steady_clock::time_point()>;

MCPToolRateLimiter();
explicit MCPToolRateLimiter(Clock clock);

// Try to consume one slot in the bucket identified by
// (tool_name, principal). Returns whether the call is allowed,
// how many slots remain in the current window, and (when denied)
// how many seconds until the window resets.
AcquireDecision tryAcquire(const std::string& tool_name,
const std::string& principal,
const RateLimitConfig& config);

private:
struct Bucket {
std::int64_t remaining = 0;
std::chrono::steady_clock::time_point reset_time;
};

static std::string keyFor(const std::string& tool_name,
const std::string& principal);

Clock clock_;
std::mutex mutex_;
std::unordered_map<std::string, Bucket> buckets_;
};

} // namespace flapi
29 changes: 29 additions & 0 deletions src/mcp_tool_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ MCPToolExecutionResult MCPToolHandler::executeTool(const MCPToolCallRequest& req
}
}

// W2.5: per-tool rate limit. Runs before argument validation so a
// flooded caller never consumes DB or template I/O. The principal
// key falls back to a stable marker when the request is anonymous
// so anonymous floods share one bucket per tool.
if (endpoint_config->mcp_tool && endpoint_config->mcp_tool->rate_limit.enabled) {
std::string principal = "anonymous";
auto ctx_it = request.context.find("auth.username");
if (ctx_it != request.context.end() && !ctx_it->second.empty()) {
principal = ctx_it->second;
}
auto decision = rate_limiter.tryAcquire(request.tool_name,
principal,
endpoint_config->mcp_tool->rate_limit);
if (!decision.allowed) {
std::unordered_map<std::string, std::string> metadata;
metadata["tool_name"] = request.tool_name;
metadata["rate_limited"] = "true";
metadata["retry_after_seconds"] = std::to_string(decision.retry_after_seconds);
MCPToolExecutionResult result;
result.success = false;
result.error_message = "Rate limit exceeded for tool '" + request.tool_name +
"'. Retry after " +
std::to_string(decision.retry_after_seconds) +
" seconds.";
result.metadata = std::move(metadata);
return result;
}
}

// W2.2 dry-run: peel `_dryRun` off the arguments before validation so
// the reserved key never reaches the unknown-parameter check. A copy
// of the arguments is made because MCPToolCallRequest is const here.
Expand Down
49 changes: 49 additions & 0 deletions src/mcp_tool_rate_limiter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#include "mcp_tool_rate_limiter.hpp"

#include "config_manager.hpp"

namespace flapi {

MCPToolRateLimiter::MCPToolRateLimiter()
: clock_([]() { return std::chrono::steady_clock::now(); }) {}

MCPToolRateLimiter::MCPToolRateLimiter(Clock clock)
: clock_(std::move(clock)) {}

std::string MCPToolRateLimiter::keyFor(const std::string& tool_name,
const std::string& principal) {
return tool_name + "|" + principal;
}

MCPToolRateLimiter::AcquireDecision MCPToolRateLimiter::tryAcquire(
const std::string& tool_name,
const std::string& principal,
const RateLimitConfig& config) {

if (!config.enabled) {
return {true, /*remaining=*/0, /*retry_after=*/0};
}

const auto key = keyFor(tool_name, principal);
const auto now = clock_();

std::lock_guard<std::mutex> guard(mutex_);
auto& bucket = buckets_[key];

// Initialise or roll over an expired window.
if (now >= bucket.reset_time) {
bucket.remaining = config.max;
bucket.reset_time = now + std::chrono::seconds(config.interval);
}

if (bucket.remaining <= 0) {
const auto until = std::chrono::duration_cast<std::chrono::seconds>(
bucket.reset_time - now).count();
return {false, /*remaining=*/0, /*retry_after=*/std::max<std::int64_t>(1, until)};
}

bucket.remaining -= 1;
return {true, bucket.remaining, /*retry_after=*/0};
}

} // namespace flapi
1 change: 1 addition & 0 deletions test/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ add_executable(flapi_tests
mcp_prompt_handler_test.cpp
mcp_request_validator_test.cpp
mcp_response_shaper_test.cpp
mcp_tool_rate_limiter_test.cpp
password_hasher_test.cpp
query_executor_test.cpp
rate_limit_key_builder_test.cpp
Expand Down
32 changes: 32 additions & 0 deletions test/cpp/endpoint_config_parser_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ template-source: test.sql
REQUIRE_FALSE(result.config.mcp_tool->response.max_rows.has_value());
REQUIRE(result.config.mcp_tool->response.redact_columns.empty());
REQUIRE_FALSE(result.config.mcp_tool->response.sample);
// Default: no per-tool rate limit configured.
REQUIRE_FALSE(result.config.mcp_tool->rate_limit.enabled);

fs::remove(yaml_file);
fs::remove(config_file);
}

TEST_CASE("EndpointConfigParser: Parse MCP Tool with rate-limit", "[endpoint_parser][ratelimit]") {
std::string yaml_content = R"(
mcp-tool:
name: throttled_tool
description: Tool with a per-tool rate limit
rate-limit:
enabled: true
max: 5
interval: 30
template-source: test.sql
connection:
- test_db
)";

std::string yaml_file = createTempYamlFile(yaml_content);
std::string config_file = createMinimalFlapiConfig();

ConfigManager manager{fs::path(config_file)};
EndpointConfigParser parser(manager.getYamlParser(), &manager);
auto result = parser.parseFromFile(yaml_file);

REQUIRE(result.success == true);
REQUIRE(result.config.mcp_tool->rate_limit.enabled);
REQUIRE(result.config.mcp_tool->rate_limit.max == 5);
REQUIRE(result.config.mcp_tool->rate_limit.interval == 30);

fs::remove(yaml_file);
fs::remove(config_file);
Expand Down
Loading