Skip to content

Commit 3285d40

Browse files
feat: Mcp rate Limiter (#16)
Co-authored-by: John Leider <[email protected]>
1 parent 8decc2e commit 3285d40

File tree

3 files changed

+178
-2
lines changed

3 files changed

+178
-2
lines changed

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
1111

1212
import { intro } from './cli/intro.js'
1313
import packageJson from '../package.json' with { type: 'json' }
14+
import { startHttpServer } from './transports/http.js'
1415

1516
import { registerPrompts } from '#prompts/index'
1617
import { registerResources } from '#resources/index'
1718
import { registerTools } from '#tools/index'
18-
import { startHttpServer } from '#transports/http'
1919

2020
function parseArgs () {
2121
const args = process.argv.slice(2)

src/transports/http.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ import { registerPrompts } from '#prompts/index'
1313
import { registerResources } from '#resources/index'
1414
import { registerTools } from '#tools/index'
1515
import packageJson from '../../package.json' with { type: 'json' }
16+
import { RateLimiter } from '../utils/rate-limiter.js'
17+
import type { RateLimiterOptions } from '../utils/rate-limiter.js'
1618

1719
export interface HttpServerOptions {
1820
port?: number
1921
host?: string
2022
path?: string
23+
rateLimit?: RateLimiterOptions
2124
}
2225

2326
async function createMcpServer () {
@@ -49,10 +52,12 @@ export async function startHttpServer (options: HttpServerOptions = {}): Promise
4952
const host = options.host ?? 'localhost'
5053
const path = options.path ?? '/mcp'
5154

55+
const rateLimiter = options.rateLimit ? new RateLimiter(options.rateLimit) : null
56+
5257
return new Promise((resolve, reject) => {
5358
const httpServer = createServer(async (req, res) => {
5459
try {
55-
await handleRequest(req, res, path)
60+
await handleRequest(req, res, path, rateLimiter, options.rateLimit)
5661
} catch (error) {
5762
console.error('Error handling request:', error)
5863
if (!res.headersSent) {
@@ -71,10 +76,27 @@ export async function startHttpServer (options: HttpServerOptions = {}): Promise
7176
})
7277
}
7378

79+
function getClientIdentifier (req: IncomingMessage): string {
80+
// Use session ID if available (for stateful mode)
81+
const sessionId = req.headers['mcp-session-id']
82+
if (sessionId && typeof sessionId === 'string') {
83+
return `session:${sessionId}`
84+
}
85+
86+
// Fall back to IP address
87+
const forwarded = req.headers['x-forwarded-for']
88+
const ip = forwarded
89+
? (typeof forwarded === 'string' ? forwarded.split(',')[0] : forwarded[0])
90+
: req.socket.remoteAddress
91+
return `ip:${ip ?? 'unknown'}`
92+
}
93+
7494
async function handleRequest (
7595
req: IncomingMessage,
7696
res: ServerResponse,
7797
mcpPath: string,
98+
rateLimiter: RateLimiter | null,
99+
rateLimitOptions?: RateLimiterOptions,
78100
): Promise<void> {
79101
console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`)
80102

@@ -89,6 +111,31 @@ async function handleRequest (
89111
return
90112
}
91113

114+
if (rateLimiter && req.url !== '/health' && req.url !== '/') {
115+
const clientId = getClientIdentifier(req)
116+
const rateLimitResult = rateLimiter.check(clientId)
117+
118+
if (rateLimitOptions) {
119+
res.setHeader('X-RateLimit-Limit', rateLimitOptions.maxRequests.toString())
120+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining.toString())
121+
res.setHeader('X-RateLimit-Reset', new Date(rateLimitResult.resetTime).toISOString())
122+
}
123+
124+
if (!rateLimitResult.allowed) {
125+
res.writeHead(429, {
126+
'Content-Type': 'application/json',
127+
'Retry-After': rateLimitResult.retryAfter?.toString() ?? '60',
128+
})
129+
res.end(JSON.stringify({
130+
error: 'Too Many Requests',
131+
message: 'Rate limit exceeded. Please try again later.',
132+
retryAfter: rateLimitResult.retryAfter,
133+
resetTime: new Date(rateLimitResult.resetTime).toISOString(),
134+
}))
135+
return
136+
}
137+
}
138+
92139
// Health check endpoint
93140
if (req.url === '/health' && req.method === 'GET') {
94141
res.writeHead(200, { 'Content-Type': 'application/json' })

src/utils/rate-limiter.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Rate Limiter Implementation
3+
*
4+
* Implements a sliding window rate limiter to control request rates
5+
*/
6+
7+
export interface RateLimiterOptions {
8+
/** Maximum number of requests allowed in the time window */
9+
maxRequests: number
10+
/** Time window in milliseconds */
11+
windowMs: number
12+
/** Optional key generator function for identifying clients */
13+
keyGenerator?: (identifier: string) => string
14+
}
15+
16+
interface RateLimitRecord {
17+
timestamps: number[]
18+
}
19+
20+
export class RateLimiter {
21+
private records: Map<string, RateLimitRecord> = new Map()
22+
private options: Required<RateLimiterOptions>
23+
private cleanupInterval: NodeJS.Timeout | null = null
24+
25+
constructor (options: RateLimiterOptions) {
26+
this.options = {
27+
maxRequests: options.maxRequests,
28+
windowMs: options.windowMs,
29+
keyGenerator: options.keyGenerator ?? ((id: string) => id),
30+
}
31+
32+
// Clean up old records every minute to prevent memory leaks
33+
this.cleanupInterval = setInterval(() => {
34+
this.cleanup()
35+
}, 60_000)
36+
}
37+
38+
/**
39+
* Check if a request should be allowed
40+
* @param identifier - Client identifier (e.g., IP address, session ID)
41+
* @returns Object with allowed status and retry information
42+
*/
43+
check (identifier: string): {
44+
allowed: boolean
45+
remaining: number
46+
resetTime: number
47+
retryAfter?: number
48+
} {
49+
const key = this.options.keyGenerator(identifier)
50+
const now = Date.now()
51+
const windowStart = now - this.options.windowMs
52+
53+
let record = this.records.get(key)
54+
if (!record) {
55+
record = { timestamps: [] }
56+
this.records.set(key, record)
57+
}
58+
59+
// Remove timestamps outside the current window
60+
record.timestamps = record.timestamps.filter(ts => ts > windowStart)
61+
62+
// Check if limit is exceeded
63+
const allowed = record.timestamps.length < this.options.maxRequests
64+
const remaining = Math.max(0, this.options.maxRequests - record.timestamps.length)
65+
66+
// Calculate reset time (when the oldest request will fall outside the window)
67+
const resetTime = record.timestamps.length > 0
68+
? record.timestamps[0] + this.options.windowMs
69+
: now + this.options.windowMs
70+
71+
let retryAfter: number | undefined
72+
if (!allowed && record.timestamps.length > 0) {
73+
// Calculate how long to wait until the oldest request expires
74+
retryAfter = Math.ceil((record.timestamps[0] + this.options.windowMs - now) / 1000)
75+
}
76+
77+
// If allowed, record this request
78+
if (allowed) {
79+
record.timestamps.push(now)
80+
}
81+
82+
return {
83+
allowed,
84+
remaining: allowed ? remaining - 1 : remaining,
85+
resetTime,
86+
retryAfter,
87+
}
88+
}
89+
90+
/**
91+
* Reset rate limit for a specific identifier
92+
*/
93+
reset (identifier: string): void {
94+
const key = this.options.keyGenerator(identifier)
95+
this.records.delete(key)
96+
}
97+
98+
/**
99+
* Clear all rate limit records
100+
*/
101+
clear (): void {
102+
this.records.clear()
103+
}
104+
105+
destroy (): void {
106+
if (this.cleanupInterval) {
107+
clearInterval(this.cleanupInterval)
108+
this.cleanupInterval = null
109+
}
110+
}
111+
112+
/**
113+
* Clean up expired records
114+
*/
115+
private cleanup (): void {
116+
const now = Date.now()
117+
const windowStart = now - this.options.windowMs
118+
119+
for (const [key, record] of this.records.entries()) {
120+
// Remove timestamps outside the current window
121+
record.timestamps = record.timestamps.filter(ts => ts > windowStart)
122+
123+
// Remove the record entirely if it has no timestamps
124+
if (record.timestamps.length === 0) {
125+
this.records.delete(key)
126+
}
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)