mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-02 07:13:17 +00:00
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
|
|
/**
|
|||
|
|
* In-memory rate limiting for the dispatch path.
|
|||
|
|
*
|
|||
|
|
* design.md §7.3 (Security / abuse): "per-session tool-call cap + per-tool rate
|
|||
|
|
* limit". This is the injected limiter the dispatcher consults before running a
|
|||
|
|
* handler. An in-memory implementation is sufficient for this PR (single
|
|||
|
|
* process); a distributed limiter (e.g. DynamoDB/Redis token bucket) is a
|
|||
|
|
* later-phase concern once the servers are horizontally scaled.
|
|||
|
|
*
|
|||
|
|
* Two independent limits, both keyed by the caller `sub`:
|
|||
|
|
* 1. A per-session total tool-call cap (blast-radius bound on any one user/
|
|||
|
|
* session — design.md §2.5 prompt-injection containment).
|
|||
|
|
* 2. A per-(sub, tool) sliding-window rate limit.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/** Thrown when a limit is exceeded. Adapters map this to HTTP 429. */
|
|||
|
|
export class RateLimitError extends Error {
|
|||
|
|
readonly retryAfterMs: number | undefined;
|
|||
|
|
constructor(message: string, retryAfterMs?: number) {
|
|||
|
|
super(message);
|
|||
|
|
this.name = 'RateLimitError';
|
|||
|
|
this.retryAfterMs = retryAfterMs;
|
|||
|
|
Object.setPrototypeOf(this, new.target.prototype);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The limiter contract the dispatcher depends on. `check` throws
|
|||
|
|
* {@link RateLimitError} when the call must be rejected, otherwise records the
|
|||
|
|
* call and returns.
|
|||
|
|
*/
|
|||
|
|
export interface RateLimiter {
|
|||
|
|
/** Record-and-check a single tool call for `sub`/`tool`. Throws on limit. */
|
|||
|
|
check(sub: string, tool: string): void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface RateLimitConfig {
|
|||
|
|
/** Max total tool calls a single `sub` may make for the limiter's lifetime. */
|
|||
|
|
sessionCap: number;
|
|||
|
|
/** Max calls to one tool per `sub` within {@link windowMs}. */
|
|||
|
|
perToolLimit: number;
|
|||
|
|
/** Sliding-window length in milliseconds for the per-tool limit. */
|
|||
|
|
windowMs: number;
|
|||
|
|
/** Injectable clock for deterministic tests; defaults to `Date.now`. */
|
|||
|
|
now?: () => number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Process-local {@link RateLimiter}. Construct one per server (or per test).
|
|||
|
|
*
|
|||
|
|
* Note: not shared across processes — acceptable for this PR per design.md §7.3
|
|||
|
|
* ("in-memory token bucket is fine for this PR").
|
|||
|
|
*/
|
|||
|
|
export class InMemoryRateLimiter implements RateLimiter {
|
|||
|
|
private readonly sessionCount = new Map<string, number>();
|
|||
|
|
private readonly toolHits = new Map<string, number[]>();
|
|||
|
|
private readonly now: () => number;
|
|||
|
|
|
|||
|
|
constructor(private readonly config: RateLimitConfig) {
|
|||
|
|
this.now = config.now ?? ((): number => Date.now());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
check(sub: string, tool: string): void {
|
|||
|
|
// 1. Per-session total cap.
|
|||
|
|
const sessionTotal = (this.sessionCount.get(sub) ?? 0) + 1;
|
|||
|
|
if (sessionTotal > this.config.sessionCap) {
|
|||
|
|
throw new RateLimitError(
|
|||
|
|
`Per-session tool-call cap of ${this.config.sessionCap} exceeded for "${sub}".`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Per-(sub, tool) sliding window.
|
|||
|
|
const key = `${sub} |