@casys/mcp-server 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Backpressure middleware.
3
+ *
4
+ * Controls concurrent execution using RequestQueue.
5
+ * Acquires a slot before calling next(), releases after.
6
+ *
7
+ * @module lib/server/middleware/backpressure
8
+ */
9
+
10
+ import type { RequestQueue } from "../concurrency/request-queue.js";
11
+ import type { Middleware } from "./types.js";
12
+
13
+ /**
14
+ * Create a backpressure middleware.
15
+ *
16
+ * Wraps the downstream pipeline in acquire/release:
17
+ * ```
18
+ * acquire() → next() → release()
19
+ * ```
20
+ *
21
+ * The slot is always released, even if an error occurs.
22
+ *
23
+ * @param queue - RequestQueue instance with configured concurrency limits
24
+ */
25
+ export function createBackpressureMiddleware(
26
+ queue: RequestQueue,
27
+ ): Middleware {
28
+ return async (_ctx, next) => {
29
+ await queue.acquire();
30
+ try {
31
+ return await next();
32
+ } finally {
33
+ queue.release();
34
+ }
35
+ };
36
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Middleware module for ConcurrentMCPServer.
3
+ *
4
+ * @module lib/server/middleware
5
+ */
6
+
7
+ // Types
8
+ export type {
9
+ Middleware,
10
+ MiddlewareContext,
11
+ MiddlewareResult,
12
+ NextFunction,
13
+ } from "./types.js";
14
+
15
+ // Runner
16
+ export { createMiddlewareRunner } from "./runner.js";
17
+
18
+ // Built-in middlewares
19
+ export { createRateLimitMiddleware } from "./rate-limit.js";
20
+ export { createValidationMiddleware } from "./validation.js";
21
+ export { createBackpressureMiddleware } from "./backpressure.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Rate limiting middleware.
3
+ *
4
+ * Extracted from ConcurrentMCPServer's inline rate limit logic.
5
+ *
6
+ * @module lib/server/middleware/rate-limit
7
+ */
8
+
9
+ import type { RateLimiter } from "../concurrency/rate-limiter.js";
10
+ import type { RateLimitOptions } from "../types.js";
11
+ import type { Middleware } from "./types.js";
12
+
13
+ /**
14
+ * Create a rate limiting middleware.
15
+ *
16
+ * Behavior depends on `options.onLimitExceeded`:
17
+ * - `'reject'`: throws immediately when limit is exceeded
18
+ * - `'wait'` (default): waits with backoff until a slot is available
19
+ *
20
+ * @param limiter - RateLimiter instance
21
+ * @param options - Rate limit configuration
22
+ */
23
+ export function createRateLimitMiddleware(
24
+ limiter: RateLimiter,
25
+ options: RateLimitOptions,
26
+ ): Middleware {
27
+ return async (ctx, next) => {
28
+ const key =
29
+ options.keyExtractor?.({ toolName: ctx.toolName, args: ctx.args }) ??
30
+ "default";
31
+
32
+ if (options.onLimitExceeded === "reject") {
33
+ if (!limiter.checkLimit(key)) {
34
+ const waitTime = limiter.getTimeUntilSlot(key);
35
+ throw new Error(
36
+ `Rate limit exceeded. Retry after ${Math.ceil(waitTime / 1000)}s`,
37
+ );
38
+ }
39
+ } else {
40
+ await limiter.waitForSlot(key);
41
+ }
42
+
43
+ return next();
44
+ };
45
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Middleware pipeline runner.
3
+ *
4
+ * Composes an array of middlewares into a single callable function
5
+ * using the onion model: each middleware wraps the next.
6
+ *
7
+ * @module lib/server/middleware/runner
8
+ */
9
+
10
+ import type {
11
+ Middleware,
12
+ MiddlewareContext,
13
+ MiddlewareResult,
14
+ } from "./types.js";
15
+
16
+ /**
17
+ * Create a middleware runner that composes middlewares + a final handler.
18
+ *
19
+ * Execution order (onion model):
20
+ * ```
21
+ * m1-before → m2-before → handler → m2-after → m1-after
22
+ * ```
23
+ *
24
+ * @param middlewares - Array of middleware functions to execute in order
25
+ * @param handler - Final handler (the tool execution)
26
+ * @returns A function that runs the full pipeline for a given context
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const run = createMiddlewareRunner(
31
+ * [rateLimitMiddleware, validationMiddleware],
32
+ * async (ctx) => toolHandler(ctx.args),
33
+ * );
34
+ * const result = await run({ toolName: "my_tool", args: { x: 1 } });
35
+ * ```
36
+ */
37
+ export function createMiddlewareRunner(
38
+ middlewares: Middleware[],
39
+ handler: (ctx: MiddlewareContext) => Promise<MiddlewareResult>,
40
+ ): (ctx: MiddlewareContext) => Promise<MiddlewareResult> {
41
+ return (ctx: MiddlewareContext) => {
42
+ let index = 0;
43
+ let handlerCalled = false;
44
+
45
+ // deno-lint-ignore require-await
46
+ const next = async (): Promise<MiddlewareResult> => {
47
+ if (index < middlewares.length) {
48
+ const middleware = middlewares[index++];
49
+ return middleware(ctx, next);
50
+ }
51
+ if (handlerCalled) {
52
+ throw new Error(
53
+ "[MiddlewareRunner] next() called after pipeline already completed. " +
54
+ "A middleware may be calling next() multiple times.",
55
+ );
56
+ }
57
+ handlerCalled = true;
58
+ return handler(ctx);
59
+ };
60
+
61
+ return next();
62
+ };
63
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Middleware pipeline types for ConcurrentMCPServer.
3
+ *
4
+ * Provides an onion-model middleware system (similar to Koa/Hono)
5
+ * where each middleware wraps the next, enabling before/after logic.
6
+ *
7
+ * @module lib/server/middleware/types
8
+ */
9
+
10
+ /**
11
+ * Context passed through the middleware pipeline.
12
+ * Each middleware can read and enrich the context.
13
+ */
14
+ export interface MiddlewareContext {
15
+ /** Name of the tool being called */
16
+ toolName: string;
17
+
18
+ /** Tool arguments */
19
+ args: Record<string, unknown>;
20
+
21
+ /** HTTP request (only present for HTTP transport, undefined for STDIO) */
22
+ request?: Request;
23
+
24
+ /** Session ID (only present for HTTP transport) */
25
+ sessionId?: string;
26
+
27
+ /** Extensible by middlewares (e.g., authInfo added by auth middleware) */
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ /**
32
+ * Result returned by a middleware or the final handler.
33
+ */
34
+ export type MiddlewareResult = unknown;
35
+
36
+ /**
37
+ * Function to invoke the next middleware in the chain.
38
+ */
39
+ export type NextFunction = () => Promise<MiddlewareResult>;
40
+
41
+ /**
42
+ * A middleware function.
43
+ *
44
+ * Receives the context and a `next()` function to call the next middleware.
45
+ * Can short-circuit the pipeline by not calling `next()`.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * const loggingMiddleware: Middleware = async (ctx, next) => {
50
+ * console.log(`Before: ${ctx.toolName}`);
51
+ * const result = await next();
52
+ * console.log(`After: ${ctx.toolName}`);
53
+ * return result;
54
+ * };
55
+ * ```
56
+ */
57
+ export type Middleware = (
58
+ ctx: MiddlewareContext,
59
+ next: NextFunction,
60
+ ) => Promise<MiddlewareResult>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Schema validation middleware.
3
+ *
4
+ * Validates tool arguments against JSON Schema before execution.
5
+ *
6
+ * @module lib/server/middleware/validation
7
+ */
8
+
9
+ import type { SchemaValidator } from "../validation/schema-validator.js";
10
+ import type { Middleware } from "./types.js";
11
+
12
+ /**
13
+ * Create a schema validation middleware.
14
+ *
15
+ * Validates `ctx.args` against the registered schema for `ctx.toolName`.
16
+ * Throws with a descriptive error if validation fails.
17
+ *
18
+ * @param validator - SchemaValidator instance with pre-registered schemas
19
+ */
20
+ export function createValidationMiddleware(
21
+ validator: SchemaValidator,
22
+ ): Middleware {
23
+ // deno-lint-ignore require-await
24
+ return async (ctx, next) => {
25
+ validator.validateOrThrow(ctx.toolName, ctx.args);
26
+ return next();
27
+ };
28
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Server Metrics Collector for @casys/mcp-server
3
+ *
4
+ * In-memory counters, histograms, and gauges with Prometheus text format export.
5
+ * Designed to be embedded in ConcurrentMCPServer — no external dependencies.
6
+ *
7
+ * @module lib/server/observability/metrics
8
+ */
9
+
10
+ /**
11
+ * Histogram bucket
12
+ */
13
+ interface HistogramBucket {
14
+ le: number;
15
+ count: number;
16
+ }
17
+
18
+ /**
19
+ * Latency histogram with cumulative buckets
20
+ */
21
+ interface Histogram {
22
+ buckets: HistogramBucket[];
23
+ sum: number;
24
+ count: number;
25
+ }
26
+
27
+ /**
28
+ * Default histogram buckets (milliseconds)
29
+ */
30
+ const DEFAULT_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
31
+
32
+ function createHistogram(buckets: number[] = DEFAULT_BUCKETS): Histogram {
33
+ return {
34
+ buckets: buckets.map((le) => ({ le, count: 0 })),
35
+ sum: 0,
36
+ count: 0,
37
+ };
38
+ }
39
+
40
+ function observeHistogram(histogram: Histogram, value: number): void {
41
+ histogram.sum += value;
42
+ histogram.count++;
43
+ for (const bucket of histogram.buckets) {
44
+ if (value <= bucket.le) {
45
+ bucket.count++;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Metrics snapshot returned by getMetrics()
52
+ */
53
+ export interface ServerMetricsSnapshot {
54
+ counters: {
55
+ tool_calls_total: number;
56
+ tool_calls_success: number;
57
+ tool_calls_failed: number;
58
+ requests_rate_limited: number;
59
+ requests_rejected_backpressure: number;
60
+ auth_success: number;
61
+ auth_failed: number;
62
+ auth_cache_hits: number;
63
+ sessions_created: number;
64
+ sessions_expired: number;
65
+ };
66
+ histograms: {
67
+ tool_call_duration_ms: Histogram;
68
+ };
69
+ gauges: {
70
+ active_requests: number;
71
+ queued_requests: number;
72
+ active_sessions: number;
73
+ sse_clients: number;
74
+ rate_limiter_keys: number;
75
+ };
76
+ collected_at: number;
77
+ uptime_seconds: number;
78
+ }
79
+
80
+ /**
81
+ * Tool call metrics by tool name
82
+ */
83
+ interface PerToolMetrics {
84
+ calls: number;
85
+ success: number;
86
+ failed: number;
87
+ totalDurationMs: number;
88
+ }
89
+
90
+ /**
91
+ * Server metrics collector.
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * const metrics = new ServerMetrics();
96
+ * metrics.recordToolCall("my_tool", true, 42);
97
+ * console.log(metrics.toPrometheusFormat());
98
+ * ```
99
+ */
100
+ export class ServerMetrics {
101
+ private startTime = Date.now();
102
+
103
+ // Counters
104
+ private toolCallsTotal = 0;
105
+ private toolCallsSuccess = 0;
106
+ private toolCallsFailed = 0;
107
+ private requestsRateLimited = 0;
108
+ private requestsRejectedBackpressure = 0;
109
+ private authSuccess = 0;
110
+ private authFailed = 0;
111
+ private authCacheHits = 0;
112
+ private sessionsCreated = 0;
113
+ private sessionsExpired = 0;
114
+
115
+ // Histogram
116
+ private toolCallDuration = createHistogram();
117
+
118
+ // Per-tool breakdown
119
+ private perTool = new Map<string, PerToolMetrics>();
120
+
121
+ // Gauges (set externally via setGauge)
122
+ private activeRequests = 0;
123
+ private queuedRequests = 0;
124
+ private activeSessions = 0;
125
+ private sseClients = 0;
126
+ private rateLimiterKeys = 0;
127
+
128
+ /**
129
+ * Record a completed tool call
130
+ */
131
+ recordToolCall(toolName: string, success: boolean, durationMs: number): void {
132
+ this.toolCallsTotal++;
133
+ if (success) {
134
+ this.toolCallsSuccess++;
135
+ } else {
136
+ this.toolCallsFailed++;
137
+ }
138
+ observeHistogram(this.toolCallDuration, durationMs);
139
+
140
+ // Per-tool
141
+ let pt = this.perTool.get(toolName);
142
+ if (!pt) {
143
+ pt = { calls: 0, success: 0, failed: 0, totalDurationMs: 0 };
144
+ this.perTool.set(toolName, pt);
145
+ }
146
+ pt.calls++;
147
+ if (success) pt.success++;
148
+ else pt.failed++;
149
+ pt.totalDurationMs += durationMs;
150
+ }
151
+
152
+ recordRateLimited(): void {
153
+ this.requestsRateLimited++;
154
+ }
155
+
156
+ recordBackpressureRejected(): void {
157
+ this.requestsRejectedBackpressure++;
158
+ }
159
+
160
+ recordAuth(success: boolean): void {
161
+ if (success) this.authSuccess++;
162
+ else this.authFailed++;
163
+ }
164
+
165
+ recordAuthCacheHit(): void {
166
+ this.authCacheHits++;
167
+ }
168
+
169
+ recordSessionCreated(): void {
170
+ this.sessionsCreated++;
171
+ }
172
+
173
+ recordSessionExpired(count: number): void {
174
+ this.sessionsExpired += count;
175
+ }
176
+
177
+ /**
178
+ * Update gauge values (called periodically or on-demand)
179
+ */
180
+ setGauges(gauges: {
181
+ activeRequests?: number;
182
+ queuedRequests?: number;
183
+ activeSessions?: number;
184
+ sseClients?: number;
185
+ rateLimiterKeys?: number;
186
+ }): void {
187
+ if (gauges.activeRequests !== undefined) {
188
+ this.activeRequests = gauges.activeRequests;
189
+ }
190
+ if (gauges.queuedRequests !== undefined) {
191
+ this.queuedRequests = gauges.queuedRequests;
192
+ }
193
+ if (gauges.activeSessions !== undefined) {
194
+ this.activeSessions = gauges.activeSessions;
195
+ }
196
+ if (gauges.sseClients !== undefined) this.sseClients = gauges.sseClients;
197
+ if (gauges.rateLimiterKeys !== undefined) {
198
+ this.rateLimiterKeys = gauges.rateLimiterKeys;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Get current metrics snapshot
204
+ */
205
+ getSnapshot(): ServerMetricsSnapshot {
206
+ return {
207
+ counters: {
208
+ tool_calls_total: this.toolCallsTotal,
209
+ tool_calls_success: this.toolCallsSuccess,
210
+ tool_calls_failed: this.toolCallsFailed,
211
+ requests_rate_limited: this.requestsRateLimited,
212
+ requests_rejected_backpressure: this.requestsRejectedBackpressure,
213
+ auth_success: this.authSuccess,
214
+ auth_failed: this.authFailed,
215
+ auth_cache_hits: this.authCacheHits,
216
+ sessions_created: this.sessionsCreated,
217
+ sessions_expired: this.sessionsExpired,
218
+ },
219
+ histograms: {
220
+ tool_call_duration_ms: { ...this.toolCallDuration },
221
+ },
222
+ gauges: {
223
+ active_requests: this.activeRequests,
224
+ queued_requests: this.queuedRequests,
225
+ active_sessions: this.activeSessions,
226
+ sse_clients: this.sseClients,
227
+ rate_limiter_keys: this.rateLimiterKeys,
228
+ },
229
+ collected_at: Date.now(),
230
+ uptime_seconds: Math.floor((Date.now() - this.startTime) / 1000),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Prometheus text format export
236
+ */
237
+ toPrometheusFormat(prefix = "mcp_server"): string {
238
+ const m = this.getSnapshot();
239
+ const lines: string[] = [];
240
+
241
+ // --- Counters ---
242
+ const counter = (name: string, help: string, value: number) => {
243
+ lines.push(`# HELP ${prefix}_${name} ${help}`);
244
+ lines.push(`# TYPE ${prefix}_${name} counter`);
245
+ lines.push(`${prefix}_${name} ${value}`);
246
+ };
247
+
248
+ counter(
249
+ "tool_calls_total",
250
+ "Total tool calls",
251
+ m.counters.tool_calls_total,
252
+ );
253
+ counter(
254
+ "tool_calls_success_total",
255
+ "Successful tool calls",
256
+ m.counters.tool_calls_success,
257
+ );
258
+ counter(
259
+ "tool_calls_failed_total",
260
+ "Failed tool calls",
261
+ m.counters.tool_calls_failed,
262
+ );
263
+ counter(
264
+ "requests_rate_limited_total",
265
+ "Requests rejected by rate limiter",
266
+ m.counters.requests_rate_limited,
267
+ );
268
+ counter(
269
+ "requests_backpressure_total",
270
+ "Requests rejected by backpressure",
271
+ m.counters.requests_rejected_backpressure,
272
+ );
273
+ counter(
274
+ "auth_success_total",
275
+ "Successful auth verifications",
276
+ m.counters.auth_success,
277
+ );
278
+ counter(
279
+ "auth_failed_total",
280
+ "Failed auth verifications",
281
+ m.counters.auth_failed,
282
+ );
283
+ counter(
284
+ "auth_cache_hits_total",
285
+ "Auth token cache hits",
286
+ m.counters.auth_cache_hits,
287
+ );
288
+ counter(
289
+ "sessions_created_total",
290
+ "Sessions created",
291
+ m.counters.sessions_created,
292
+ );
293
+ counter(
294
+ "sessions_expired_total",
295
+ "Sessions expired by cleanup",
296
+ m.counters.sessions_expired,
297
+ );
298
+
299
+ // --- Per-tool counters ---
300
+ lines.push(`# HELP ${prefix}_tool_calls_by_name Tool calls by tool name`);
301
+ lines.push(`# TYPE ${prefix}_tool_calls_by_name counter`);
302
+ for (const [name, pt] of this.perTool) {
303
+ lines.push(
304
+ `${prefix}_tool_calls_by_name{tool="${name}",status="success"} ${pt.success}`,
305
+ );
306
+ lines.push(
307
+ `${prefix}_tool_calls_by_name{tool="${name}",status="failed"} ${pt.failed}`,
308
+ );
309
+ }
310
+
311
+ // --- Histogram ---
312
+ const h = m.histograms.tool_call_duration_ms;
313
+ lines.push(
314
+ `# HELP ${prefix}_tool_call_duration_ms Tool call duration in milliseconds`,
315
+ );
316
+ lines.push(`# TYPE ${prefix}_tool_call_duration_ms histogram`);
317
+ for (const bucket of h.buckets) {
318
+ lines.push(
319
+ `${prefix}_tool_call_duration_ms_bucket{le="${bucket.le}"} ${bucket.count}`,
320
+ );
321
+ }
322
+ lines.push(`${prefix}_tool_call_duration_ms_bucket{le="+Inf"} ${h.count}`);
323
+ lines.push(`${prefix}_tool_call_duration_ms_sum ${h.sum}`);
324
+ lines.push(`${prefix}_tool_call_duration_ms_count ${h.count}`);
325
+
326
+ // --- Gauges ---
327
+ const gauge = (name: string, help: string, value: number) => {
328
+ lines.push(`# HELP ${prefix}_${name} ${help}`);
329
+ lines.push(`# TYPE ${prefix}_${name} gauge`);
330
+ lines.push(`${prefix}_${name} ${value}`);
331
+ };
332
+
333
+ gauge(
334
+ "active_requests",
335
+ "Currently executing requests",
336
+ m.gauges.active_requests,
337
+ );
338
+ gauge(
339
+ "queued_requests",
340
+ "Requests waiting in queue",
341
+ m.gauges.queued_requests,
342
+ );
343
+ gauge("active_sessions", "Active HTTP sessions", m.gauges.active_sessions);
344
+ gauge("sse_clients", "Connected SSE clients", m.gauges.sse_clients);
345
+ gauge(
346
+ "rate_limiter_keys",
347
+ "Tracked rate limiter keys",
348
+ m.gauges.rate_limiter_keys,
349
+ );
350
+ gauge("uptime_seconds", "Server uptime in seconds", m.uptime_seconds);
351
+
352
+ return lines.join("\n") + "\n";
353
+ }
354
+
355
+ /**
356
+ * Reset all metrics
357
+ */
358
+ reset(): void {
359
+ this.toolCallsTotal = 0;
360
+ this.toolCallsSuccess = 0;
361
+ this.toolCallsFailed = 0;
362
+ this.requestsRateLimited = 0;
363
+ this.requestsRejectedBackpressure = 0;
364
+ this.authSuccess = 0;
365
+ this.authFailed = 0;
366
+ this.authCacheHits = 0;
367
+ this.sessionsCreated = 0;
368
+ this.sessionsExpired = 0;
369
+ this.toolCallDuration = createHistogram();
370
+ this.perTool.clear();
371
+ this.activeRequests = 0;
372
+ this.queuedRequests = 0;
373
+ this.activeSessions = 0;
374
+ this.sseClients = 0;
375
+ this.rateLimiterKeys = 0;
376
+ this.startTime = Date.now();
377
+ }
378
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Observability module for @casys/mcp-server
3
+ *
4
+ * - OTel tracing (spans on tool calls, auth events)
5
+ * - Metrics collection (counters, histograms, gauges)
6
+ * - Prometheus text format export
7
+ *
8
+ * @module lib/server/observability
9
+ */
10
+
11
+ export {
12
+ endToolCallSpan,
13
+ getServerTracer,
14
+ isOtelEnabled,
15
+ recordAuthEvent,
16
+ startToolCallSpan,
17
+ type ToolCallSpanAttributes,
18
+ } from "./otel.js";
19
+
20
+ export { ServerMetrics, type ServerMetricsSnapshot } from "./metrics.js";