@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,1899 @@
1
+ /**
2
+ * Concurrent MCP Server Framework
3
+ *
4
+ * High-performance MCP server with built-in concurrency control,
5
+ * backpressure, and optional sampling support.
6
+ *
7
+ * Wraps the official @modelcontextprotocol/sdk with production-ready
8
+ * concurrency features.
9
+ *
10
+ * @module lib/server/concurrent-server
11
+ */
12
+
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListToolsRequestSchema,
18
+ ListResourcesRequestSchema,
19
+ ReadResourceRequestSchema,
20
+ } from "@modelcontextprotocol/sdk/types.js";
21
+ import { Hono } from "hono";
22
+ import { cors } from "hono/cors";
23
+ import { RequestQueue } from "./concurrency/request-queue.js";
24
+ import { SamplingBridge } from "./sampling/sampling-bridge.js";
25
+ import { RateLimiter } from "./concurrency/rate-limiter.js";
26
+ import { SchemaValidator } from "./validation/schema-validator.js";
27
+ import { createMiddlewareRunner } from "./middleware/runner.js";
28
+ import { createRateLimitMiddleware } from "./middleware/rate-limit.js";
29
+ import { createValidationMiddleware } from "./middleware/validation.js";
30
+ import { createBackpressureMiddleware } from "./middleware/backpressure.js";
31
+ import type {
32
+ Middleware,
33
+ MiddlewareContext,
34
+ MiddlewareResult,
35
+ } from "./middleware/types.js";
36
+ import { serve, type ServeHandle, unrefTimer } from "./runtime/runtime.js";
37
+ import {
38
+ AuthError,
39
+ createAuthMiddleware,
40
+ createForbiddenResponse,
41
+ createUnauthorizedResponse,
42
+ extractBearerToken,
43
+ } from "./auth/middleware.js";
44
+ import { createScopeMiddleware } from "./auth/scope-middleware.js";
45
+ import { createAuthProviderFromConfig, loadAuthConfig } from "./auth/config.js";
46
+ import type { AuthProvider } from "./auth/provider.js";
47
+ import type {
48
+ ConcurrentServerOptions,
49
+ HttpRateLimitContext,
50
+ HttpServerOptions,
51
+ MCPResource,
52
+ MCPTool,
53
+ QueueMetrics,
54
+ ResourceContent,
55
+ ResourceHandler,
56
+ ToolHandler,
57
+ } from "./types.js";
58
+ import { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
59
+ import { buildCspHeader, injectCspMetaTag } from "./security/csp.js";
60
+ import { ServerMetrics } from "./observability/metrics.js";
61
+ import { endToolCallSpan, startToolCallSpan } from "./observability/otel.js";
62
+
63
+ /**
64
+ * Tool definition with handler
65
+ */
66
+ interface ToolWithHandler extends MCPTool {
67
+ handler: ToolHandler;
68
+ }
69
+
70
+ /**
71
+ * Internal tracking of registered resources
72
+ */
73
+ interface RegisteredResourceInfo {
74
+ resource: MCPResource;
75
+ handler: ResourceHandler;
76
+ }
77
+
78
+ /**
79
+ * SSE client connection for Streamable HTTP
80
+ */
81
+ interface SSEClient {
82
+ sessionId: string;
83
+ controller: ReadableStreamDefaultController<Uint8Array>;
84
+ createdAt: number;
85
+ lastEventId: number;
86
+ }
87
+
88
+ const DEFAULT_MAX_BODY_BYTES = 1_000_000;
89
+
90
+ class BodyTooLargeError extends Error {
91
+ constructor(maxBytes: number) {
92
+ super(`Payload too large. Max ${maxBytes} bytes.`);
93
+ this.name = "BodyTooLargeError";
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Generate a cryptographically secure session ID
99
+ */
100
+ function generateSessionId(): string {
101
+ const bytes = new Uint8Array(16);
102
+ crypto.getRandomValues(bytes);
103
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
104
+ }
105
+
106
+ function getClientIpFromHeaders(headers: Headers): string {
107
+ const forwarded = headers.get("x-forwarded-for");
108
+ if (forwarded) {
109
+ return forwarded.split(",")[0]?.trim() || "unknown";
110
+ }
111
+ return headers.get("x-real-ip") ??
112
+ headers.get("cf-connecting-ip") ??
113
+ "unknown";
114
+ }
115
+
116
+ async function readBodyWithLimit(
117
+ request: Request,
118
+ maxBytes: number | null,
119
+ ): Promise<Uint8Array> {
120
+ const contentLength = request.headers.get("content-length");
121
+ if (maxBytes !== null && contentLength) {
122
+ const length = Number(contentLength);
123
+ if (!Number.isNaN(length) && length > maxBytes) {
124
+ throw new BodyTooLargeError(maxBytes);
125
+ }
126
+ }
127
+
128
+ if (!request.body) {
129
+ return new Uint8Array();
130
+ }
131
+
132
+ const reader = request.body.getReader();
133
+ const chunks: Uint8Array[] = [];
134
+ let total = 0;
135
+
136
+ while (true) {
137
+ const { done, value } = await reader.read();
138
+ if (done) break;
139
+ if (!value) continue;
140
+ total += value.length;
141
+ if (maxBytes !== null && total > maxBytes) {
142
+ throw new BodyTooLargeError(maxBytes);
143
+ }
144
+ chunks.push(value);
145
+ }
146
+
147
+ const body = new Uint8Array(total);
148
+ let offset = 0;
149
+ for (const chunk of chunks) {
150
+ body.set(chunk, offset);
151
+ offset += chunk.length;
152
+ }
153
+ return body;
154
+ }
155
+
156
+ /**
157
+ * ConcurrentMCPServer provides a high-performance MCP server
158
+ *
159
+ * Features:
160
+ * - Wraps official @modelcontextprotocol/sdk
161
+ * - Concurrency limiting (default: 10 max concurrent)
162
+ * - Multiple backpressure strategies (sleep/queue/reject)
163
+ * - Optional bidirectional sampling support
164
+ * - Metrics for monitoring
165
+ * - Graceful shutdown
166
+ *
167
+ * @example
168
+ * ```typescript
169
+ * const server = new ConcurrentMCPServer({
170
+ * name: "my-server",
171
+ * version: "1.0.0",
172
+ * maxConcurrent: 5,
173
+ * backpressureStrategy: 'queue'
174
+ * });
175
+ *
176
+ * server.registerTools(myTools, myHandlers);
177
+ * await server.start();
178
+ * ```
179
+ */
180
+ export class ConcurrentMCPServer {
181
+ private mcpServer: McpServer;
182
+ private requestQueue: RequestQueue;
183
+ private rateLimiter: RateLimiter | null = null;
184
+ private schemaValidator: SchemaValidator | null = null;
185
+ private samplingBridge: SamplingBridge | null = null;
186
+ private tools = new Map<string, ToolWithHandler>();
187
+ private resources = new Map<string, RegisteredResourceInfo>();
188
+ private options: ConcurrentServerOptions;
189
+ private started = false;
190
+ private resourceHandlersInstalled = false;
191
+
192
+ // Middleware pipeline
193
+ private customMiddlewares: Middleware[] = [];
194
+ private middlewareRunner:
195
+ | ((ctx: MiddlewareContext) => Promise<MiddlewareResult>)
196
+ | null = null;
197
+
198
+ // Auth provider (set from options.auth or auto-configured from env)
199
+ private authProvider: AuthProvider | null = null;
200
+
201
+ // Observability
202
+ private serverMetrics = new ServerMetrics();
203
+
204
+ // Streamable HTTP session management
205
+ private sessions = new Map<
206
+ string,
207
+ { createdAt: number; lastActivity: number }
208
+ >();
209
+ private sseClients = new Map<string, SSEClient[]>(); // sessionId -> clients
210
+ private sessionCleanupTimer: ReturnType<typeof setInterval> | null = null;
211
+ private static readonly SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
212
+ private static readonly SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
213
+ private static readonly SESSION_GRACE_PERIOD_MS = 60 * 1000; // 60s grace for in-flight requests
214
+ private static readonly MAX_SESSIONS = 10_000;
215
+
216
+ // Per-IP rate limiter for initialize requests (anti-session-exhaustion)
217
+ private initRateLimiter = new RateLimiter({
218
+ maxRequests: 10,
219
+ windowMs: 60_000,
220
+ });
221
+
222
+ constructor(options: ConcurrentServerOptions) {
223
+ this.options = options;
224
+
225
+ // Create SDK MCP server
226
+ this.mcpServer = new McpServer(
227
+ {
228
+ name: options.name,
229
+ version: options.version,
230
+ },
231
+ {
232
+ capabilities: {
233
+ tools: {},
234
+ },
235
+ },
236
+ );
237
+
238
+ // Create request queue with concurrency control
239
+ this.requestQueue = new RequestQueue({
240
+ maxConcurrent: options.maxConcurrent ?? 10,
241
+ strategy: options.backpressureStrategy ?? "sleep",
242
+ sleepMs: options.backpressureSleepMs ?? 10,
243
+ });
244
+
245
+ // Optional rate limiting
246
+ if (options.rateLimit) {
247
+ this.rateLimiter = new RateLimiter({
248
+ maxRequests: options.rateLimit.maxRequests,
249
+ windowMs: options.rateLimit.windowMs,
250
+ });
251
+ }
252
+
253
+ // Optional schema validation
254
+ if (options.validateSchema) {
255
+ this.schemaValidator = new SchemaValidator();
256
+ }
257
+
258
+ // Optional sampling support
259
+ if (options.enableSampling && options.samplingClient) {
260
+ this.samplingBridge = new SamplingBridge(options.samplingClient);
261
+ }
262
+
263
+ // Setup MCP protocol handlers
264
+ this.setupHandlers();
265
+
266
+ // Pre-declare resources capability so resources can be added after start()
267
+ if (options.expectResources) {
268
+ this.installResourceHandlers();
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Pre-install resources/list and resources/read handlers on the low-level
274
+ * SDK Server. This declares the `resources` capability BEFORE transport
275
+ * connection, allowing dynamic resource registration after start().
276
+ *
277
+ * The handlers read from `this.resources` Map which is populated lazily
278
+ * by registerResource() calls (e.g., after async MCP discovery).
279
+ */
280
+ private installResourceHandlers(): void {
281
+ const server = this.mcpServer.server;
282
+
283
+ // Declare resources capability before transport connects
284
+ server.registerCapabilities({ resources: {} });
285
+
286
+ // resources/list — returns currently registered resources
287
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
288
+ return {
289
+ resources: Array.from(this.resources.values()).map((r) => ({
290
+ uri: r.resource.uri,
291
+ name: r.resource.name,
292
+ description: r.resource.description,
293
+ mimeType: r.resource.mimeType ?? MCP_APP_MIME_TYPE,
294
+ })),
295
+ };
296
+ });
297
+
298
+ // resources/read — serve resource content by URI
299
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
300
+ const uri = request.params.uri;
301
+ const info = this.resources.get(uri);
302
+ if (!info) {
303
+ throw new Error(`Resource not found: ${uri}`);
304
+ }
305
+
306
+ try {
307
+ const content = await info.handler(new URL(uri));
308
+ const finalContent = this.applyResourceCsp(content);
309
+ return { contents: [finalContent] };
310
+ } catch (error) {
311
+ this.log(
312
+ `[ERROR] Resource handler failed for ${uri}: ${
313
+ error instanceof Error ? error.message : String(error)
314
+ }`,
315
+ );
316
+ throw error;
317
+ }
318
+ });
319
+
320
+ this.resourceHandlersInstalled = true;
321
+ this.log("Resources capability pre-declared (expectResources: true)");
322
+ }
323
+
324
+ /**
325
+ * Setup MCP protocol request handlers
326
+ */
327
+ private setupHandlers(): void {
328
+ const server = this.mcpServer.server;
329
+
330
+ // Wire up "initialized" notification callback (post-handshake)
331
+ server.oninitialized = () => {
332
+ this.initializedCallback?.();
333
+ };
334
+
335
+ // tools/list handler
336
+ server.setRequestHandler(ListToolsRequestSchema, () => {
337
+ return {
338
+ tools: Array.from(this.tools.values()).map((t) => ({
339
+ name: t.name,
340
+ description: t.description,
341
+ inputSchema: t.inputSchema,
342
+ _meta: t._meta, // Always include, even if undefined (MCP Apps discovery)
343
+ })),
344
+ };
345
+ });
346
+
347
+ // tools/call handler (delegates to middleware pipeline)
348
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
349
+ const toolName = request.params.name;
350
+ const args = request.params.arguments || {};
351
+
352
+ try {
353
+ const result = await this.executeToolCall(toolName, args);
354
+
355
+ // If handler returns a pre-formatted MCP result (has content array),
356
+ // pass it through without re-wrapping. This supports proxy/gateway
357
+ // patterns where the handler builds the complete response.
358
+ if (this.isPreformattedResult(result)) {
359
+ return result;
360
+ }
361
+
362
+ // Format response according to MCP protocol
363
+ const tool = this.tools.get(toolName);
364
+ const response: {
365
+ content: Array<{ type: "text"; text: string }>;
366
+ _meta?: Record<string, unknown>;
367
+ } = {
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: typeof result === "string"
372
+ ? result
373
+ : JSON.stringify(result, null, 2),
374
+ },
375
+ ],
376
+ };
377
+ if (tool?._meta) {
378
+ response._meta = tool._meta as Record<string, unknown>;
379
+ }
380
+ return response;
381
+ } catch (error) {
382
+ this.log(
383
+ `Error executing tool ${request.params.name}: ${
384
+ error instanceof Error ? error.message : String(error)
385
+ }`,
386
+ );
387
+ throw error;
388
+ }
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Register tools with their handlers
394
+ *
395
+ * @param tools - Array of tool definitions (MCP format)
396
+ * @param handlers - Map of tool name to handler function
397
+ */
398
+ registerTools(
399
+ tools: MCPTool[],
400
+ handlers: Map<string, ToolHandler>,
401
+ ): void {
402
+ if (this.started) {
403
+ throw new Error(
404
+ "[ConcurrentMCPServer] Cannot register tools after server started. " +
405
+ "Call registerTools() before start() or startHttp().",
406
+ );
407
+ }
408
+ for (const tool of tools) {
409
+ const handler = handlers.get(tool.name);
410
+ if (!handler) {
411
+ throw new Error(`No handler provided for tool: ${tool.name}`);
412
+ }
413
+
414
+ this.tools.set(tool.name, {
415
+ ...tool,
416
+ handler,
417
+ });
418
+
419
+ // Register schema for validation if enabled
420
+ if (this.schemaValidator) {
421
+ this.schemaValidator.addSchema(tool.name, tool.inputSchema);
422
+ }
423
+ }
424
+
425
+ this.log(`Registered ${tools.length} tools`);
426
+ }
427
+
428
+ /**
429
+ * Register a single tool
430
+ *
431
+ * @param tool - Tool definition
432
+ * @param handler - Tool handler function
433
+ */
434
+ registerTool(tool: MCPTool, handler: ToolHandler): void {
435
+ if (this.started) {
436
+ throw new Error(
437
+ "[ConcurrentMCPServer] Cannot register tools after server started. " +
438
+ "Call registerTool() before start() or startHttp().",
439
+ );
440
+ }
441
+ this.tools.set(tool.name, {
442
+ ...tool,
443
+ handler,
444
+ });
445
+
446
+ // Register schema for validation if enabled
447
+ if (this.schemaValidator) {
448
+ this.schemaValidator.addSchema(tool.name, tool.inputSchema);
449
+ }
450
+
451
+ this.log(`Registered tool: ${tool.name}`);
452
+ }
453
+
454
+ /**
455
+ * Register a tool after the server has started (live registration).
456
+ *
457
+ * Unlike registerTool(), this can be called while the server is running.
458
+ * The tool becomes immediately available for tools/list and tools/call.
459
+ *
460
+ * Use case: relay proxy where tools are registered dynamically
461
+ * when remote owners connect/disconnect their tunnels.
462
+ *
463
+ * @param tool - Tool definition
464
+ * @param handler - Tool handler function
465
+ */
466
+ registerToolLive(tool: MCPTool, handler: ToolHandler): void {
467
+ this.tools.set(tool.name, {
468
+ ...tool,
469
+ handler,
470
+ });
471
+
472
+ if (this.schemaValidator) {
473
+ this.schemaValidator.addSchema(tool.name, tool.inputSchema);
474
+ }
475
+
476
+ this.log(`Live-registered tool: ${tool.name} (total: ${this.tools.size})`);
477
+ }
478
+
479
+ /**
480
+ * Unregister a tool (removes it from tools/list and tools/call).
481
+ *
482
+ * Can be called before or after start.
483
+ * In-flight calls to this tool will complete normally.
484
+ *
485
+ * @param toolName - Name of the tool to remove
486
+ * @returns true if the tool was found and removed
487
+ */
488
+ unregisterTool(toolName: string): boolean {
489
+ const deleted = this.tools.delete(toolName);
490
+ if (deleted) {
491
+ this.log(`Unregistered tool: ${toolName} (remaining: ${this.tools.size})`);
492
+ }
493
+ return deleted;
494
+ }
495
+
496
+ // ============================================
497
+ // Middleware Pipeline
498
+ // ============================================
499
+
500
+ /**
501
+ * Add a custom middleware to the pipeline.
502
+ * Must be called before start()/startHttp().
503
+ *
504
+ * Custom middlewares execute between rate-limit and validation:
505
+ * rate-limit → **custom middlewares** → validation → backpressure → handler
506
+ *
507
+ * @param middleware - Middleware function
508
+ * @returns this (for chaining)
509
+ *
510
+ * @example
511
+ * ```typescript
512
+ * server.use(async (ctx, next) => {
513
+ * console.log(`Calling ${ctx.toolName}`);
514
+ * const result = await next();
515
+ * console.log(`Done ${ctx.toolName}`);
516
+ * return result;
517
+ * });
518
+ * ```
519
+ */
520
+ use(middleware: Middleware): this {
521
+ if (this.started) {
522
+ throw new Error(
523
+ "[ConcurrentMCPServer] Cannot add middleware after server started. " +
524
+ "Call use() before start() or startHttp().",
525
+ );
526
+ }
527
+ this.customMiddlewares.push(middleware);
528
+ this.middlewareRunner = null; // Invalidate cached runner
529
+ return this;
530
+ }
531
+
532
+ /**
533
+ * Build the middleware pipeline from config + custom middlewares.
534
+ * Called once at start()/startHttp() time.
535
+ *
536
+ * Pipeline order:
537
+ * rate-limit → auth → custom middlewares → scope-check → validation → backpressure → handler
538
+ */
539
+ private buildPipeline(): void {
540
+ const pipeline: Middleware[] = [];
541
+
542
+ // 1. Rate limiting (if configured)
543
+ if (this.rateLimiter && this.options.rateLimit) {
544
+ pipeline.push(
545
+ createRateLimitMiddleware(this.rateLimiter, this.options.rateLimit),
546
+ );
547
+ }
548
+
549
+ // 2. Auth middleware (if auth provider is set)
550
+ if (this.authProvider) {
551
+ pipeline.push(createAuthMiddleware(this.authProvider));
552
+ }
553
+
554
+ // 3. Custom middlewares (logging, tracing, etc.)
555
+ pipeline.push(...this.customMiddlewares);
556
+
557
+ // 4. Scope enforcement (if any tool has requiredScopes)
558
+ const toolScopes = new Map<string, string[]>();
559
+ for (const [name, tool] of this.tools) {
560
+ if (tool.requiredScopes?.length) {
561
+ toolScopes.set(name, tool.requiredScopes);
562
+ }
563
+ }
564
+ if (toolScopes.size > 0) {
565
+ pipeline.push(createScopeMiddleware(toolScopes));
566
+ }
567
+
568
+ // 5. Schema validation (if enabled)
569
+ if (this.schemaValidator) {
570
+ pipeline.push(createValidationMiddleware(this.schemaValidator));
571
+ }
572
+
573
+ // 6. Backpressure (always)
574
+ pipeline.push(createBackpressureMiddleware(this.requestQueue));
575
+
576
+ this.middlewareRunner = createMiddlewareRunner(pipeline, (ctx) => {
577
+ const tool = this.tools.get(ctx.toolName);
578
+ if (!tool) {
579
+ throw new Error(`Unknown tool: ${ctx.toolName}`);
580
+ }
581
+ return Promise.resolve(tool.handler(ctx.args));
582
+ });
583
+ }
584
+
585
+ /**
586
+ * Execute a tool call through the middleware pipeline.
587
+ * Unified entry point for both STDIO and HTTP transports.
588
+ *
589
+ * @param toolName - Name of the tool to call
590
+ * @param args - Tool arguments
591
+ * @param request - HTTP request (undefined for STDIO)
592
+ * @param sessionId - HTTP session ID (undefined for STDIO)
593
+ * @returns Tool execution result
594
+ */
595
+ private async executeToolCall(
596
+ toolName: string,
597
+ args: Record<string, unknown>,
598
+ request?: Request,
599
+ sessionId?: string,
600
+ ): Promise<MiddlewareResult> {
601
+ if (!this.middlewareRunner) {
602
+ throw new Error(
603
+ "[ConcurrentMCPServer] Pipeline not built. Call start() or startHttp() first.",
604
+ );
605
+ }
606
+
607
+ const ctx: MiddlewareContext = {
608
+ toolName,
609
+ args,
610
+ request,
611
+ sessionId,
612
+ };
613
+
614
+ // OTel span + metrics
615
+ const span = startToolCallSpan(toolName, {
616
+ "mcp.tool.name": toolName,
617
+ "mcp.server.name": this.options.name,
618
+ "mcp.transport": request ? "http" : "stdio",
619
+ "mcp.session.id": sessionId,
620
+ });
621
+
622
+ // Update gauges before execution
623
+ const queueMetrics = this.requestQueue.getMetrics();
624
+ this.serverMetrics.setGauges({
625
+ activeRequests: queueMetrics.inFlight,
626
+ queuedRequests: queueMetrics.queued,
627
+ activeSessions: this.sessions.size,
628
+ sseClients: this.getSSEClientCount(),
629
+ rateLimiterKeys: this.rateLimiter?.getMetrics().keys ?? 0,
630
+ });
631
+
632
+ const start = performance.now();
633
+ try {
634
+ const result = await this.middlewareRunner(ctx);
635
+ const durationMs = performance.now() - start;
636
+ this.serverMetrics.recordToolCall(toolName, true, durationMs);
637
+ endToolCallSpan(span, true, durationMs);
638
+ return result;
639
+ } catch (error) {
640
+ const durationMs = performance.now() - start;
641
+ const errorMsg = error instanceof Error ? error.message : String(error);
642
+ this.serverMetrics.recordToolCall(toolName, false, durationMs);
643
+ endToolCallSpan(span, false, durationMs, errorMsg);
644
+ throw error;
645
+ }
646
+ }
647
+
648
+ // ============================================
649
+ // Resource Registration (MCP Apps SEP-1865)
650
+ // ============================================
651
+
652
+ /**
653
+ * Validate resource URI scheme
654
+ * Logs warning if not using ui:// scheme (MCP Apps standard)
655
+ */
656
+ /**
657
+ * Apply CSP meta tag injection to HTML resource content (if configured).
658
+ * Only transforms HTML content (checks mimeType); non-HTML passes through.
659
+ */
660
+ private applyResourceCsp(
661
+ content: import("./types.js").ResourceContent,
662
+ ): import("./types.js").ResourceContent {
663
+ if (!this.options.resourceCsp) return content;
664
+ if (!content.mimeType?.includes("text/html")) return content;
665
+
666
+ const cspValue = buildCspHeader(this.options.resourceCsp);
667
+ return {
668
+ ...content,
669
+ text: injectCspMetaTag(content.text ?? "", cspValue),
670
+ };
671
+ }
672
+
673
+ private validateResourceUri(uri: string): void {
674
+ if (!uri.startsWith(MCP_APP_URI_SCHEME)) {
675
+ this.log(
676
+ `[WARN] Resource URI "${uri}" does not use ${MCP_APP_URI_SCHEME} scheme. ` +
677
+ `MCP Apps standard requires ui:// URIs.`,
678
+ );
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Register a single resource
684
+ *
685
+ * @param resource - Resource definition with uri, name, description
686
+ * @param handler - Callback that returns ResourceContent when resource is read
687
+ * @throws Error if resource with same URI already registered
688
+ *
689
+ * @example
690
+ * ```typescript
691
+ * server.registerResource(
692
+ * { uri: "ui://my-server/viewer", name: "Viewer", description: "Data viewer" },
693
+ * async (uri) => ({
694
+ * uri: uri.toString(),
695
+ * mimeType: MCP_APP_MIME_TYPE,
696
+ * text: "<html>...</html>"
697
+ * })
698
+ * );
699
+ * ```
700
+ */
701
+ registerResource(resource: MCPResource, handler: ResourceHandler): void {
702
+ // Validate URI scheme
703
+ this.validateResourceUri(resource.uri);
704
+
705
+ // Check for duplicate
706
+ if (this.resources.has(resource.uri)) {
707
+ throw new Error(
708
+ `[ConcurrentMCPServer] Resource already registered: ${resource.uri}`,
709
+ );
710
+ }
711
+
712
+ if (this.resourceHandlersInstalled) {
713
+ // expectResources mode: handlers are already installed on the low-level
714
+ // server. Just add to our internal registry — the handlers read from
715
+ // this.resources dynamically.
716
+ this.resources.set(resource.uri, { resource, handler });
717
+ } else {
718
+ // Standard mode: register via SDK (must be called before start())
719
+ this.mcpServer.registerResource(
720
+ resource.name,
721
+ resource.uri,
722
+ {
723
+ description: resource.description,
724
+ mimeType: resource.mimeType ?? MCP_APP_MIME_TYPE,
725
+ },
726
+ async (uri: URL) => {
727
+ try {
728
+ const content = await handler(uri);
729
+ const finalContent = this.applyResourceCsp(content);
730
+ return { contents: [finalContent] };
731
+ } catch (error) {
732
+ this.log(
733
+ `[ERROR] Resource handler failed for ${uri}: ${
734
+ error instanceof Error ? error.message : String(error)
735
+ }`,
736
+ );
737
+ throw error;
738
+ }
739
+ },
740
+ );
741
+
742
+ // Track in our registry
743
+ this.resources.set(resource.uri, { resource, handler });
744
+ }
745
+
746
+ this.log(`Registered resource: ${resource.name} (${resource.uri})`);
747
+ }
748
+
749
+ /**
750
+ * Register multiple resources
751
+ *
752
+ * @param resources - Array of resource definitions
753
+ * @param handlers - Map of URI to handler function
754
+ * @throws Error if any resource is missing a handler (fail-fast)
755
+ */
756
+ registerResources(
757
+ resources: MCPResource[],
758
+ handlers: Map<string, ResourceHandler>,
759
+ ): void {
760
+ // Validate all handlers exist BEFORE registering any (fail-fast)
761
+ const missingHandlers: string[] = [];
762
+ for (const resource of resources) {
763
+ if (!handlers.has(resource.uri)) {
764
+ missingHandlers.push(resource.uri);
765
+ }
766
+ }
767
+
768
+ if (missingHandlers.length > 0) {
769
+ throw new Error(
770
+ `[ConcurrentMCPServer] Missing handlers for resources:\n` +
771
+ missingHandlers.map((uri) => ` - ${uri}`).join("\n"),
772
+ );
773
+ }
774
+
775
+ // Validate no duplicates exist BEFORE registering any (atomic behavior)
776
+ const duplicateUris: string[] = [];
777
+ for (const resource of resources) {
778
+ if (this.resources.has(resource.uri)) {
779
+ duplicateUris.push(resource.uri);
780
+ }
781
+ }
782
+
783
+ if (duplicateUris.length > 0) {
784
+ throw new Error(
785
+ `[ConcurrentMCPServer] Resources already registered:\n` +
786
+ duplicateUris.map((uri) => ` - ${uri}`).join("\n"),
787
+ );
788
+ }
789
+
790
+ // All validations passed, register resources
791
+ for (const resource of resources) {
792
+ const handler = handlers.get(resource.uri);
793
+ if (!handler) {
794
+ // Should never happen after validation, but defensive check
795
+ throw new Error(
796
+ `[ConcurrentMCPServer] Handler disappeared for ${resource.uri}`,
797
+ );
798
+ }
799
+ this.registerResource(resource, handler);
800
+ }
801
+
802
+ this.log(`Registered ${resources.length} resources`);
803
+ }
804
+
805
+ /**
806
+ * Start the MCP server with stdio transport
807
+ */
808
+ async start(): Promise<void> {
809
+ if (this.started) {
810
+ throw new Error("Server already started");
811
+ }
812
+
813
+ // Build middleware pipeline before connecting transport
814
+ this.buildPipeline();
815
+
816
+ const transport = new StdioServerTransport();
817
+ await this.mcpServer.server.connect(transport);
818
+
819
+ this.started = true;
820
+
821
+ const rateLimitInfo = this.options.rateLimit
822
+ ? `, rate limit: ${this.options.rateLimit.maxRequests}/${this.options.rateLimit.windowMs}ms`
823
+ : "";
824
+ const validationInfo = this.options.validateSchema
825
+ ? ", schema validation: on"
826
+ : "";
827
+
828
+ this.log(
829
+ `Server started (max concurrent: ${
830
+ this.options.maxConcurrent ?? 10
831
+ }, strategy: ${
832
+ this.options.backpressureStrategy ?? "sleep"
833
+ }${rateLimitInfo}${validationInfo})`,
834
+ );
835
+ this.log(`Tools available: ${this.tools.size}`);
836
+ }
837
+
838
+ /**
839
+ * Clean up expired sessions to prevent memory leaks.
840
+ * Removes sessions that haven't had activity within SESSION_TTL_MS.
841
+ */
842
+ private cleanupSessions(): void {
843
+ const now = Date.now();
844
+ const ttlWithGrace = ConcurrentMCPServer.SESSION_TTL_MS +
845
+ ConcurrentMCPServer.SESSION_GRACE_PERIOD_MS;
846
+ let cleaned = 0;
847
+ for (const [sessionId, session] of this.sessions) {
848
+ if (now - session.lastActivity > ttlWithGrace) {
849
+ this.sessions.delete(sessionId);
850
+ // Also clean up SSE clients for this session
851
+ const clients = this.sseClients.get(sessionId);
852
+ if (clients) {
853
+ for (const client of clients) {
854
+ try {
855
+ client.controller.close();
856
+ } catch { /* already closed */ }
857
+ }
858
+ this.sseClients.delete(sessionId);
859
+ }
860
+ cleaned++;
861
+ }
862
+ }
863
+ if (cleaned > 0) {
864
+ this.serverMetrics.recordSessionExpired(cleaned);
865
+ this.log(
866
+ `Session cleanup: removed ${cleaned} expired sessions (${this.sessions.size} remaining)`,
867
+ );
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Stop the server gracefully
873
+ */
874
+ async stop(): Promise<void> {
875
+ if (!this.started) {
876
+ return;
877
+ }
878
+
879
+ // Stop session cleanup timer
880
+ if (this.sessionCleanupTimer) {
881
+ clearInterval(this.sessionCleanupTimer);
882
+ this.sessionCleanupTimer = null;
883
+ }
884
+
885
+ // Cancel pending sampling requests
886
+ if (this.samplingBridge) {
887
+ this.samplingBridge.cancelAll();
888
+ }
889
+
890
+ // Close all SSE clients BEFORE shutting down HTTP server.
891
+ // Deno.serve().shutdown() waits for all connections to drain,
892
+ // so long-lived SSE connections must be closed first to avoid blocking.
893
+ for (const [sessionId, clients] of this.sseClients) {
894
+ for (const client of clients) {
895
+ try {
896
+ client.controller.close();
897
+ } catch { /* already closed */ }
898
+ }
899
+ this.sseClients.delete(sessionId);
900
+ }
901
+
902
+ // Stop HTTP server if running
903
+ if (this.httpServer) {
904
+ await this.httpServer.shutdown();
905
+ this.httpServer = null;
906
+ }
907
+
908
+ await this.mcpServer.server.close();
909
+ this.started = false;
910
+
911
+ this.log("Server stopped");
912
+ }
913
+
914
+ // ============================================
915
+ // HTTP Server Support
916
+ // ============================================
917
+
918
+ private httpServer: ServeHandle | null = null;
919
+
920
+ /**
921
+ * Start the MCP server with HTTP transport (Streamable HTTP compatible)
922
+ *
923
+ * This creates an HTTP server that handles MCP JSON-RPC requests.
924
+ * Supports tools/list, tools/call, resources/list, resources/read.
925
+ *
926
+ * @param options - HTTP server options
927
+ * @returns Server instance with shutdown method
928
+ *
929
+ * @example
930
+ * ```typescript
931
+ * const server = new ConcurrentMCPServer({ name: "my-server", version: "1.0.0" });
932
+ * server.registerTools(tools, handlers);
933
+ * server.registerResource(resource, handler);
934
+ *
935
+ * const http = await server.startHttp({ port: 3000 });
936
+ * // Server running on http://localhost:3000
937
+ *
938
+ * // Later: await http.shutdown();
939
+ * ```
940
+ */
941
+ async startHttp(
942
+ options: HttpServerOptions,
943
+ ): Promise<
944
+ { shutdown: () => Promise<void>; addr: { hostname: string; port: number } }
945
+ > {
946
+ if (this.started) {
947
+ throw new Error("Server already started");
948
+ }
949
+
950
+ // Configure auth provider:
951
+ // 1. Programmatic (options.auth.provider) takes priority
952
+ // 2. Otherwise, auto-load from YAML + env vars
953
+ if (this.options.auth?.provider) {
954
+ this.authProvider = this.options.auth.provider;
955
+ this.log(
956
+ `Auth configured: provider=${this.authProvider.constructor.name}`,
957
+ );
958
+ } else {
959
+ const authConfig = await loadAuthConfig();
960
+ if (authConfig) {
961
+ this.authProvider = createAuthProviderFromConfig(authConfig);
962
+ this.log(
963
+ `Auth auto-configured from config: provider=${authConfig.provider}`,
964
+ );
965
+ }
966
+ }
967
+
968
+ const requireAuth = options.requireAuth ?? false;
969
+ if (requireAuth && !this.authProvider) {
970
+ throw new Error(
971
+ "[ConcurrentMCPServer] HTTP auth is required (requireAuth=true) but no auth provider is configured.",
972
+ );
973
+ }
974
+ if (!this.authProvider && !requireAuth) {
975
+ this.log(
976
+ "[WARN] HTTP auth is disabled. Set requireAuth=true or configure auth for production deployments.",
977
+ );
978
+ }
979
+
980
+ // Build middleware pipeline (includes auth if configured)
981
+ this.buildPipeline();
982
+
983
+ const hostname = options.hostname ?? "0.0.0.0";
984
+ const enableCors = options.cors ?? true;
985
+ const corsOrigins = options.corsOrigins ?? "*";
986
+ const maxBodyBytes = options.maxBodyBytes === null
987
+ ? null
988
+ : (options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
989
+ const httpRateLimit = options.ipRateLimit;
990
+ const httpRateLimiter = httpRateLimit
991
+ ? new RateLimiter({
992
+ maxRequests: httpRateLimit.maxRequests,
993
+ windowMs: httpRateLimit.windowMs,
994
+ })
995
+ : null;
996
+
997
+ // Create Hono app
998
+ const app = new Hono();
999
+
1000
+ const isWildcardCors = corsOrigins === "*" ||
1001
+ (Array.isArray(corsOrigins) && corsOrigins.includes("*"));
1002
+ if (enableCors && isWildcardCors) {
1003
+ this.log(
1004
+ "[WARN] CORS wildcard origin ('*') is active. " +
1005
+ "Use corsOrigins: ['https://your-app.example.com'] in production.",
1006
+ );
1007
+ }
1008
+
1009
+ // CORS middleware
1010
+ if (enableCors) {
1011
+ app.use(
1012
+ "*",
1013
+ cors({
1014
+ origin: corsOrigins,
1015
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
1016
+ allowHeaders: [
1017
+ "Content-Type",
1018
+ "Accept",
1019
+ "Authorization",
1020
+ "mcp-session-id",
1021
+ "mcp-protocol-version",
1022
+ "last-event-id",
1023
+ ],
1024
+ exposeHeaders: ["Content-Length", "mcp-session-id"],
1025
+ maxAge: 600,
1026
+ }),
1027
+ );
1028
+ }
1029
+
1030
+ // Health check endpoint
1031
+ app.get(
1032
+ "/health",
1033
+ (c) =>
1034
+ c.json({
1035
+ status: "ok",
1036
+ server: this.options.name,
1037
+ version: this.options.version,
1038
+ }),
1039
+ );
1040
+
1041
+ // Prometheus metrics endpoint
1042
+ app.get("/metrics", (_c) => {
1043
+ // Update gauges before serving
1044
+ const qm = this.requestQueue.getMetrics();
1045
+ this.serverMetrics.setGauges({
1046
+ activeRequests: qm.inFlight,
1047
+ queuedRequests: qm.queued,
1048
+ activeSessions: this.sessions.size,
1049
+ sseClients: this.getSSEClientCount(),
1050
+ rateLimiterKeys: this.rateLimiter?.getMetrics().keys ?? 0,
1051
+ });
1052
+ return new Response(this.serverMetrics.toPrometheusFormat(), {
1053
+ headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" },
1054
+ });
1055
+ });
1056
+
1057
+ // RFC 9728 Protected Resource Metadata endpoint
1058
+ app.get("/.well-known/oauth-protected-resource", (c) => {
1059
+ if (!this.authProvider) {
1060
+ return c.text("Not Found", 404);
1061
+ }
1062
+ return c.json(this.authProvider.getResourceMetadata());
1063
+ });
1064
+
1065
+ // Helper: build resource metadata URL safely (avoid double slash)
1066
+ const buildMetadataUrl = (resource: string): string => {
1067
+ const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
1068
+ return `${base}/.well-known/oauth-protected-resource`;
1069
+ };
1070
+
1071
+ // Auth verification helper for HTTP endpoints.
1072
+ // Returns an error Response if auth is required but token is missing/invalid.
1073
+ // Returns null if auth passes or is not configured.
1074
+ const verifyHttpAuth = async (
1075
+ request: Request,
1076
+ ): Promise<Response | null> => {
1077
+ if (!this.authProvider) return null;
1078
+
1079
+ const token = extractBearerToken(request);
1080
+ if (!token) {
1081
+ const metadata = this.authProvider.getResourceMetadata();
1082
+ return createUnauthorizedResponse(
1083
+ buildMetadataUrl(metadata.resource),
1084
+ "missing_token",
1085
+ "Authorization header with Bearer token required",
1086
+ );
1087
+ }
1088
+
1089
+ const authInfo = await this.authProvider.verifyToken(token);
1090
+ if (!authInfo) {
1091
+ const metadata = this.authProvider.getResourceMetadata();
1092
+ return createUnauthorizedResponse(
1093
+ buildMetadataUrl(metadata.resource),
1094
+ "invalid_token",
1095
+ "Invalid or expired token",
1096
+ );
1097
+ }
1098
+
1099
+ return null;
1100
+ };
1101
+
1102
+ const checkHttpRateLimit = async (
1103
+ request: Request,
1104
+ sessionId?: string,
1105
+ ): Promise<{ allowed: boolean; retryAfterMs: number }> => {
1106
+ if (!httpRateLimiter || !httpRateLimit) {
1107
+ return { allowed: true, retryAfterMs: 0 };
1108
+ }
1109
+
1110
+ const ip = getClientIpFromHeaders(request.headers);
1111
+ const context: HttpRateLimitContext = {
1112
+ ip,
1113
+ method: request.method,
1114
+ path: new URL(request.url).pathname,
1115
+ headers: request.headers,
1116
+ sessionId,
1117
+ };
1118
+ const key = httpRateLimit.keyExtractor?.(context) ?? ip;
1119
+ const behavior = httpRateLimit.onLimitExceeded ?? "reject";
1120
+
1121
+ if (behavior === "wait") {
1122
+ try {
1123
+ await httpRateLimiter.waitForSlot(key);
1124
+ return { allowed: true, retryAfterMs: 0 };
1125
+ } catch {
1126
+ return {
1127
+ allowed: false,
1128
+ retryAfterMs: Math.max(
1129
+ httpRateLimiter.getTimeUntilSlot(key),
1130
+ httpRateLimit.windowMs,
1131
+ ),
1132
+ };
1133
+ }
1134
+ }
1135
+
1136
+ if (!httpRateLimiter.checkLimit(key)) {
1137
+ return {
1138
+ allowed: false,
1139
+ retryAfterMs: httpRateLimiter.getTimeUntilSlot(key),
1140
+ };
1141
+ }
1142
+
1143
+ return { allowed: true, retryAfterMs: 0 };
1144
+ };
1145
+
1146
+ const jsonRpcResponse = (
1147
+ payload: Record<string, unknown>,
1148
+ status: number,
1149
+ headers?: Record<string, string>,
1150
+ ): Response => {
1151
+ return new Response(JSON.stringify(payload), {
1152
+ status,
1153
+ headers: {
1154
+ "Content-Type": "application/json",
1155
+ ...(headers ?? {}),
1156
+ },
1157
+ });
1158
+ };
1159
+
1160
+ // Custom routes (registered before MCP catch-all)
1161
+ if (options.customRoutes) {
1162
+ for (const route of options.customRoutes) {
1163
+ app[route.method](route.path, (c) => route.handler(c.req.raw));
1164
+ }
1165
+ }
1166
+
1167
+ // MCP endpoint - GET opens SSE stream for server→client messages (Streamable HTTP spec)
1168
+ // deno-lint-ignore no-explicit-any
1169
+ const handleMcpGet = async (c: any) => {
1170
+ const accept = c.req.header("accept") ?? "";
1171
+ const sessionId = c.req.header("mcp-session-id");
1172
+ const lastEventId = c.req.header("last-event-id");
1173
+
1174
+ const rateLimit = await checkHttpRateLimit(c.req.raw, sessionId);
1175
+ if (!rateLimit.allowed) {
1176
+ const retryAfter = Math.max(
1177
+ 1,
1178
+ Math.ceil(rateLimit.retryAfterMs / 1000),
1179
+ );
1180
+ return new Response(
1181
+ `Rate limit exceeded. Retry after ${retryAfter}s`,
1182
+ {
1183
+ status: 429,
1184
+ headers: { "Retry-After": retryAfter.toString() },
1185
+ },
1186
+ );
1187
+ }
1188
+
1189
+ // Check if client accepts SSE
1190
+ if (!accept.includes("text/event-stream")) {
1191
+ return c.text("Method Not Allowed", 405);
1192
+ }
1193
+
1194
+ // Auth gate: SSE connections require valid token when auth is configured
1195
+ const authDeniedSse = await verifyHttpAuth(c.req.raw);
1196
+ if (authDeniedSse) return authDeniedSse;
1197
+
1198
+ // Validate session if provided
1199
+ if (sessionId && !this.sessions.has(sessionId)) {
1200
+ return c.text("Session not found", 404);
1201
+ }
1202
+
1203
+ // Create SSE stream
1204
+ const encoder = new TextEncoder();
1205
+ let sseClient: SSEClient | null = null;
1206
+
1207
+ const stream = new ReadableStream<Uint8Array>({
1208
+ start: (controller) => {
1209
+ const clientSessionId = sessionId ?? "anonymous";
1210
+ const parsedEventId = lastEventId ? parseInt(lastEventId, 10) : 0;
1211
+ sseClient = {
1212
+ sessionId: clientSessionId,
1213
+ controller,
1214
+ createdAt: Date.now(),
1215
+ lastEventId: Number.isNaN(parsedEventId) ? 0 : parsedEventId,
1216
+ };
1217
+
1218
+ // Register client
1219
+ if (!this.sseClients.has(clientSessionId)) {
1220
+ this.sseClients.set(clientSessionId, []);
1221
+ }
1222
+ this.sseClients.get(clientSessionId)!.push(sseClient);
1223
+
1224
+ this.log(`SSE client connected (session: ${clientSessionId})`);
1225
+
1226
+ // Send initial comment to establish connection
1227
+ controller.enqueue(encoder.encode(": connected\n\n"));
1228
+ },
1229
+ cancel: () => {
1230
+ // Remove client on disconnect
1231
+ if (sseClient) {
1232
+ const clients = this.sseClients.get(sseClient.sessionId);
1233
+ if (clients) {
1234
+ const idx = clients.indexOf(sseClient);
1235
+ if (idx !== -1) clients.splice(idx, 1);
1236
+ if (clients.length === 0) {
1237
+ this.sseClients.delete(sseClient.sessionId);
1238
+ }
1239
+ }
1240
+ this.log(
1241
+ `SSE client disconnected (session: ${sseClient.sessionId})`,
1242
+ );
1243
+ }
1244
+ },
1245
+ });
1246
+
1247
+ return new Response(stream, {
1248
+ headers: {
1249
+ "Content-Type": "text/event-stream",
1250
+ "Cache-Control": "no-cache",
1251
+ "Connection": "keep-alive",
1252
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
1253
+ },
1254
+ });
1255
+ };
1256
+
1257
+ // deno-lint-ignore no-explicit-any
1258
+ app.get("/mcp", handleMcpGet as any);
1259
+ // deno-lint-ignore no-explicit-any
1260
+ app.get("/", handleMcpGet as any);
1261
+
1262
+ // MCP endpoint - POST handles JSON-RPC
1263
+ const handleMcpPost = async (
1264
+ c: {
1265
+ req: {
1266
+ json: () => Promise<unknown>;
1267
+ raw: Request;
1268
+ header: (name: string) => string | undefined;
1269
+ };
1270
+ json: (data: unknown, status?: number) => Response;
1271
+ },
1272
+ ) => {
1273
+ let requestId: string | number | null = null;
1274
+ try {
1275
+ const reqSessionId = c.req.header("mcp-session-id");
1276
+ const rateLimit = await checkHttpRateLimit(c.req.raw, reqSessionId);
1277
+ if (!rateLimit.allowed) {
1278
+ const retryAfter = Math.max(
1279
+ 1,
1280
+ Math.ceil(rateLimit.retryAfterMs / 1000),
1281
+ );
1282
+ return jsonRpcResponse(
1283
+ {
1284
+ jsonrpc: "2.0",
1285
+ id: null,
1286
+ error: {
1287
+ code: -32000,
1288
+ message: `Rate limit exceeded. Retry after ${retryAfter}s`,
1289
+ },
1290
+ },
1291
+ 429,
1292
+ { "Retry-After": retryAfter.toString() },
1293
+ );
1294
+ }
1295
+
1296
+ let body: {
1297
+ id?: string | number;
1298
+ method?: string;
1299
+ params?: Record<string, unknown>;
1300
+ };
1301
+ try {
1302
+ const bodyBytes = await readBodyWithLimit(c.req.raw, maxBodyBytes);
1303
+ const bodyText = new TextDecoder().decode(bodyBytes);
1304
+ const parsed = JSON.parse(bodyText);
1305
+ if (!parsed || typeof parsed !== "object") {
1306
+ throw new Error("Invalid JSON payload");
1307
+ }
1308
+ body = parsed as {
1309
+ id?: string | number;
1310
+ method?: string;
1311
+ params?: Record<string, unknown>;
1312
+ };
1313
+ } catch (error) {
1314
+ if (error instanceof BodyTooLargeError) {
1315
+ return jsonRpcResponse({
1316
+ jsonrpc: "2.0",
1317
+ id: null,
1318
+ error: { code: -32000, message: error.message },
1319
+ }, 413);
1320
+ }
1321
+ throw error;
1322
+ }
1323
+
1324
+ const { id, method, params } = body;
1325
+ requestId = id ?? null;
1326
+
1327
+ // Initialize - create session and return session ID
1328
+ // Note: initialize is NOT auth-gated (client needs to discover capabilities first)
1329
+ if (method === "initialize") {
1330
+ // Per-IP rate limit on initialize to prevent session exhaustion attacks
1331
+ const clientIp = getClientIpFromHeaders(c.req.raw.headers);
1332
+ if (!this.initRateLimiter.checkLimit(clientIp)) {
1333
+ return c.json({
1334
+ jsonrpc: "2.0",
1335
+ id,
1336
+ error: {
1337
+ code: -32000,
1338
+ message: "Too many initialize requests. Try again later.",
1339
+ },
1340
+ }, 429);
1341
+ }
1342
+
1343
+ // Guard against session exhaustion
1344
+ if (this.sessions.size >= ConcurrentMCPServer.MAX_SESSIONS) {
1345
+ this.cleanupSessions();
1346
+ if (this.sessions.size >= ConcurrentMCPServer.MAX_SESSIONS) {
1347
+ return c.json({
1348
+ jsonrpc: "2.0",
1349
+ id,
1350
+ error: { code: -32000, message: "Too many active sessions" },
1351
+ }, 503);
1352
+ }
1353
+ }
1354
+ const sessionId = generateSessionId();
1355
+ const now = Date.now();
1356
+ this.sessions.set(sessionId, { createdAt: now, lastActivity: now });
1357
+ this.serverMetrics.recordSessionCreated();
1358
+
1359
+ this.log(`New session created: ${sessionId}`);
1360
+
1361
+ return new Response(
1362
+ JSON.stringify({
1363
+ jsonrpc: "2.0",
1364
+ id,
1365
+ result: {
1366
+ protocolVersion: "2025-03-26",
1367
+ capabilities: {
1368
+ tools: {},
1369
+ resources: this.resources.size > 0 ? {} : undefined,
1370
+ },
1371
+ serverInfo: {
1372
+ name: this.options.name,
1373
+ version: this.options.version,
1374
+ },
1375
+ },
1376
+ }),
1377
+ {
1378
+ headers: {
1379
+ "Content-Type": "application/json",
1380
+ "Mcp-Session-Id": sessionId,
1381
+ },
1382
+ },
1383
+ );
1384
+ }
1385
+
1386
+ // Session validation: all methods after initialize must provide a valid session
1387
+ if (reqSessionId) {
1388
+ const session = this.sessions.get(reqSessionId);
1389
+ if (!session) {
1390
+ return c.json({
1391
+ jsonrpc: "2.0",
1392
+ id,
1393
+ error: { code: -32001, message: "Session not found or expired" },
1394
+ }, 404);
1395
+ }
1396
+ // Update last activity to prevent premature cleanup
1397
+ session.lastActivity = Date.now();
1398
+ }
1399
+
1400
+ // Tools call (delegates to middleware pipeline, which handles auth internally)
1401
+ if (method === "tools/call" && params?.name) {
1402
+ const toolName = params.name as string;
1403
+ const args = (params.arguments as Record<string, unknown>) || {};
1404
+
1405
+ try {
1406
+ const result = await this.executeToolCall(
1407
+ toolName,
1408
+ args,
1409
+ c.req.raw,
1410
+ reqSessionId,
1411
+ );
1412
+
1413
+ // Pre-formatted result: pass through as-is
1414
+ if (this.isPreformattedResult(result)) {
1415
+ return c.json({
1416
+ jsonrpc: "2.0",
1417
+ id,
1418
+ result,
1419
+ });
1420
+ }
1421
+
1422
+ const tool = this.tools.get(toolName);
1423
+ return c.json({
1424
+ jsonrpc: "2.0",
1425
+ id,
1426
+ result: {
1427
+ content: [{
1428
+ type: "text",
1429
+ text: typeof result === "string"
1430
+ ? result
1431
+ : JSON.stringify(result, null, 2),
1432
+ }],
1433
+ ...(tool?._meta && { _meta: tool._meta }),
1434
+ },
1435
+ });
1436
+ } catch (error) {
1437
+ // Handle AuthError with proper HTTP status codes
1438
+ if (error instanceof AuthError) {
1439
+ if (
1440
+ error.code === "missing_token" || error.code === "invalid_token"
1441
+ ) {
1442
+ return createUnauthorizedResponse(
1443
+ error.resourceMetadataUrl,
1444
+ error.code,
1445
+ error.message,
1446
+ );
1447
+ }
1448
+ if (error.code === "insufficient_scope") {
1449
+ return createForbiddenResponse(error.requiredScopes ?? []);
1450
+ }
1451
+ }
1452
+
1453
+ this.log(
1454
+ `Error executing tool ${toolName}: ${
1455
+ error instanceof Error ? error.message : String(error)
1456
+ }`,
1457
+ );
1458
+ const errorMessage = error instanceof Error
1459
+ ? error.message
1460
+ : "Tool execution failed";
1461
+ const errorCode = errorMessage.startsWith("Unknown tool")
1462
+ ? -32602
1463
+ : errorMessage.startsWith("Rate limit")
1464
+ ? -32000
1465
+ : -32603;
1466
+ return c.json({
1467
+ jsonrpc: "2.0",
1468
+ id,
1469
+ error: { code: errorCode, message: errorMessage },
1470
+ });
1471
+ }
1472
+ }
1473
+
1474
+ // Auth gate: all other methods after initialize require valid token (if auth configured)
1475
+ // (tools/call is handled above via the middleware pipeline which includes auth)
1476
+ const authDenied = await verifyHttpAuth(c.req.raw);
1477
+ if (authDenied) return authDenied;
1478
+
1479
+ // Tools list
1480
+ if (method === "tools/list") {
1481
+ return c.json({
1482
+ jsonrpc: "2.0",
1483
+ id,
1484
+ result: {
1485
+ tools: Array.from(this.tools.values()).map((t) => ({
1486
+ name: t.name,
1487
+ description: t.description,
1488
+ inputSchema: t.inputSchema,
1489
+ _meta: t._meta,
1490
+ })),
1491
+ },
1492
+ });
1493
+ }
1494
+
1495
+ // Resources list
1496
+ if (method === "resources/list") {
1497
+ return c.json({
1498
+ jsonrpc: "2.0",
1499
+ id,
1500
+ result: {
1501
+ resources: Array.from(this.resources.values()).map((r) => ({
1502
+ uri: r.resource.uri,
1503
+ name: r.resource.name,
1504
+ description: r.resource.description,
1505
+ mimeType: r.resource.mimeType ?? MCP_APP_MIME_TYPE,
1506
+ })),
1507
+ },
1508
+ });
1509
+ }
1510
+
1511
+ // Resources read
1512
+ if (method === "resources/read" && params?.uri) {
1513
+ const uri = params.uri as string;
1514
+ const resourceInfo = this.resources.get(uri);
1515
+
1516
+ if (!resourceInfo) {
1517
+ return c.json({
1518
+ jsonrpc: "2.0",
1519
+ id,
1520
+ error: { code: -32602, message: `Resource not found: ${uri}` },
1521
+ });
1522
+ }
1523
+
1524
+ try {
1525
+ const content = await resourceInfo.handler(new URL(uri));
1526
+ const finalContent = this.applyResourceCsp(content);
1527
+ return c.json({
1528
+ jsonrpc: "2.0",
1529
+ id,
1530
+ result: { contents: [finalContent] },
1531
+ });
1532
+ } catch (error) {
1533
+ this.log(
1534
+ `Error reading resource ${uri}: ${
1535
+ error instanceof Error ? error.message : String(error)
1536
+ }`,
1537
+ );
1538
+ return c.json({
1539
+ jsonrpc: "2.0",
1540
+ id,
1541
+ error: {
1542
+ code: -32603,
1543
+ message: error instanceof Error
1544
+ ? error.message
1545
+ : "Resource read failed",
1546
+ },
1547
+ });
1548
+ }
1549
+ }
1550
+
1551
+ // Handle notifications: must have a method and no id (JSON-RPC 2.0 notification)
1552
+ if (method && !id) {
1553
+ return new Response(null, { status: 202 });
1554
+ }
1555
+
1556
+ // Malformed request: no method at all
1557
+ if (!method) {
1558
+ return c.json({
1559
+ jsonrpc: "2.0",
1560
+ id: id ?? null,
1561
+ error: {
1562
+ code: -32600,
1563
+ message: "Invalid Request: missing 'method' field",
1564
+ },
1565
+ });
1566
+ }
1567
+
1568
+ // Method not found
1569
+ return c.json({
1570
+ jsonrpc: "2.0",
1571
+ id,
1572
+ error: { code: -32601, message: `Method not found: ${method}` },
1573
+ });
1574
+ } catch (error) {
1575
+ this.log(
1576
+ `HTTP request error: ${
1577
+ error instanceof Error ? error.message : String(error)
1578
+ }`,
1579
+ );
1580
+ return c.json({
1581
+ jsonrpc: "2.0",
1582
+ id: requestId,
1583
+ error: { code: -32700, message: "Parse error" },
1584
+ });
1585
+ }
1586
+ };
1587
+
1588
+ // deno-lint-ignore no-explicit-any
1589
+ app.post("/mcp", handleMcpPost as any);
1590
+ // deno-lint-ignore no-explicit-any
1591
+ app.post("/", handleMcpPost as any);
1592
+
1593
+ // Start server
1594
+ this.httpServer = serve(
1595
+ {
1596
+ port: options.port,
1597
+ hostname,
1598
+ maxBodyBytes,
1599
+ onListen: options.onListen ?? ((info) => {
1600
+ this.log(
1601
+ `HTTP server started on http://${info.hostname}:${info.port}`,
1602
+ );
1603
+ }),
1604
+ },
1605
+ app.fetch,
1606
+ );
1607
+
1608
+ this.started = true;
1609
+
1610
+ // Start session cleanup timer (prevents unbounded memory growth)
1611
+ this.sessionCleanupTimer = setInterval(
1612
+ () => this.cleanupSessions(),
1613
+ ConcurrentMCPServer.SESSION_CLEANUP_INTERVAL_MS,
1614
+ );
1615
+ // Don't block Deno from exiting because of cleanup timer
1616
+ unrefTimer(this.sessionCleanupTimer as unknown as number);
1617
+
1618
+ const rateLimitInfo = this.options.rateLimit
1619
+ ? `, rate limit: ${this.options.rateLimit.maxRequests}/${this.options.rateLimit.windowMs}ms`
1620
+ : "";
1621
+ const validationInfo = this.options.validateSchema
1622
+ ? ", schema validation: on"
1623
+ : "";
1624
+
1625
+ this.log(
1626
+ `Server started HTTP mode (max concurrent: ${
1627
+ this.options.maxConcurrent ?? 10
1628
+ }, strategy: ${
1629
+ this.options.backpressureStrategy ?? "sleep"
1630
+ }${rateLimitInfo}${validationInfo})`,
1631
+ );
1632
+ this.log(
1633
+ `Tools available: ${this.tools.size}, Resources: ${this.resources.size}`,
1634
+ );
1635
+
1636
+ return {
1637
+ shutdown: async () => {
1638
+ await this.stop();
1639
+ },
1640
+ addr: { hostname, port: options.port },
1641
+ };
1642
+ }
1643
+
1644
+ /**
1645
+ * Send a JSON-RPC message to all SSE clients in a session
1646
+ * Used for server-initiated notifications and requests
1647
+ *
1648
+ * @param sessionId - Session ID (or "anonymous" for clients without session)
1649
+ * @param message - JSON-RPC message to send
1650
+ */
1651
+ sendToSession(sessionId: string, message: Record<string, unknown>): void {
1652
+ const clients = this.sseClients.get(sessionId);
1653
+ if (!clients || clients.length === 0) {
1654
+ this.log(`No SSE clients for session: ${sessionId}`);
1655
+ return;
1656
+ }
1657
+
1658
+ const encoder = new TextEncoder();
1659
+ const eventId = Date.now();
1660
+ const data = `id: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`;
1661
+
1662
+ // Iterate in reverse so splice doesn't shift indices
1663
+ for (let i = clients.length - 1; i >= 0; i--) {
1664
+ const client = clients[i];
1665
+ try {
1666
+ client.controller.enqueue(encoder.encode(data));
1667
+ client.lastEventId = eventId;
1668
+ } catch {
1669
+ // Stream is closed/broken — remove zombie client to prevent memory leak
1670
+ clients.splice(i, 1);
1671
+ this.log(`Removed dead SSE client from session: ${sessionId}`);
1672
+ }
1673
+ }
1674
+
1675
+ // Clean up empty session entry
1676
+ if (clients.length === 0) {
1677
+ this.sseClients.delete(sessionId);
1678
+ }
1679
+ }
1680
+
1681
+ /**
1682
+ * Send a notification to all connected SSE clients
1683
+ *
1684
+ * @param method - Notification method name
1685
+ * @param params - Notification parameters
1686
+ */
1687
+ broadcastNotification(
1688
+ method: string,
1689
+ params?: Record<string, unknown>,
1690
+ ): void {
1691
+ const message = {
1692
+ jsonrpc: "2.0",
1693
+ method,
1694
+ params,
1695
+ };
1696
+
1697
+ for (const sessionId of this.sseClients.keys()) {
1698
+ this.sendToSession(sessionId, message);
1699
+ }
1700
+ }
1701
+
1702
+ /**
1703
+ * Get number of active SSE connections
1704
+ */
1705
+ getSSEClientCount(): number {
1706
+ let count = 0;
1707
+ for (const clients of this.sseClients.values()) {
1708
+ count += clients.length;
1709
+ }
1710
+ return count;
1711
+ }
1712
+
1713
+ /**
1714
+ * Get sampling bridge (if enabled)
1715
+ */
1716
+ getSamplingBridge(): SamplingBridge | null {
1717
+ return this.samplingBridge;
1718
+ }
1719
+
1720
+ /**
1721
+ * Get queue metrics for monitoring
1722
+ */
1723
+ getMetrics(): QueueMetrics {
1724
+ return this.requestQueue.getMetrics();
1725
+ }
1726
+
1727
+ /**
1728
+ * Get full server metrics (counters, histograms, gauges)
1729
+ */
1730
+ getServerMetrics(): import("./observability/metrics.js").ServerMetricsSnapshot {
1731
+ const qm = this.requestQueue.getMetrics();
1732
+ this.serverMetrics.setGauges({
1733
+ activeRequests: qm.inFlight,
1734
+ queuedRequests: qm.queued,
1735
+ activeSessions: this.sessions.size,
1736
+ sseClients: this.getSSEClientCount(),
1737
+ rateLimiterKeys: this.rateLimiter?.getMetrics().keys ?? 0,
1738
+ });
1739
+ return this.serverMetrics.getSnapshot();
1740
+ }
1741
+
1742
+ /**
1743
+ * Get Prometheus text format metrics
1744
+ */
1745
+ getPrometheusMetrics(): string {
1746
+ return this.serverMetrics.toPrometheusFormat();
1747
+ }
1748
+
1749
+ /**
1750
+ * Get rate limiter metrics (if rate limiting is enabled)
1751
+ */
1752
+ getRateLimitMetrics(): { keys: number; totalRequests: number } | null {
1753
+ return this.rateLimiter?.getMetrics() ?? null;
1754
+ }
1755
+
1756
+ /**
1757
+ * Get rate limiter instance (for advanced use cases)
1758
+ */
1759
+ getRateLimiter(): RateLimiter | null {
1760
+ return this.rateLimiter;
1761
+ }
1762
+
1763
+ /**
1764
+ * Get schema validator instance (for advanced use cases)
1765
+ */
1766
+ getSchemaValidator(): SchemaValidator | null {
1767
+ return this.schemaValidator;
1768
+ }
1769
+
1770
+ /**
1771
+ * Check if server is started
1772
+ */
1773
+ isStarted(): boolean {
1774
+ return this.started;
1775
+ }
1776
+
1777
+ /**
1778
+ * Get number of registered tools
1779
+ */
1780
+ getToolCount(): number {
1781
+ return this.tools.size;
1782
+ }
1783
+
1784
+ /**
1785
+ * Get tool names
1786
+ */
1787
+ getToolNames(): string[] {
1788
+ return Array.from(this.tools.keys());
1789
+ }
1790
+
1791
+ // ============================================
1792
+ // Resource Introspection (MCP Apps)
1793
+ // ============================================
1794
+
1795
+ /**
1796
+ * Get number of registered resources
1797
+ */
1798
+ getResourceCount(): number {
1799
+ return this.resources.size;
1800
+ }
1801
+
1802
+ /**
1803
+ * Get registered resource URIs
1804
+ */
1805
+ getResourceUris(): string[] {
1806
+ return Array.from(this.resources.keys());
1807
+ }
1808
+
1809
+ /**
1810
+ * Check if a resource is registered
1811
+ */
1812
+ hasResource(uri: string): boolean {
1813
+ return this.resources.has(uri);
1814
+ }
1815
+
1816
+ /**
1817
+ * Get resource info by URI (for testing/debugging)
1818
+ */
1819
+ getResourceInfo(uri: string): MCPResource | undefined {
1820
+ return this.resources.get(uri)?.resource;
1821
+ }
1822
+
1823
+ /**
1824
+ * Read resource content by URI.
1825
+ * Invokes the registered handler directly (no MCP protocol round-trip).
1826
+ * Returns null if the resource is not registered.
1827
+ */
1828
+ async readResourceContent(uri: string): Promise<ResourceContent | null> {
1829
+ const entry = this.resources.get(uri);
1830
+ if (!entry) return null;
1831
+ return await entry.handler(new URL(uri));
1832
+ }
1833
+
1834
+ /**
1835
+ * Send a JSON-RPC notification to the connected transport.
1836
+ * For stdio: writes to stdout via MCP SDK transport.
1837
+ * For HTTP: broadcasts to all SSE clients.
1838
+ *
1839
+ * @param method - Notification method (e.g. "notifications/message")
1840
+ * @param params - Notification parameters
1841
+ */
1842
+ sendNotification(
1843
+ method: string,
1844
+ params?: Record<string, unknown>,
1845
+ ): void {
1846
+ if (!this.started) return;
1847
+
1848
+ // For HTTP mode, broadcast via SSE
1849
+ if (this.httpServer) {
1850
+ this.broadcastNotification(method, params);
1851
+ return;
1852
+ }
1853
+
1854
+ // For stdio mode, send via SDK transport
1855
+ try {
1856
+ this.mcpServer.server.notification({ method, params });
1857
+ } catch {
1858
+ // Transport may not support notifications yet (pre-initialized)
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * Register a callback for the "initialized" notification.
1864
+ * Called after client sends "initialized" (post-handshake).
1865
+ */
1866
+ onInitialized(callback: () => void): void {
1867
+ this.initializedCallback = callback;
1868
+ }
1869
+
1870
+ private initializedCallback: (() => void) | null = null;
1871
+
1872
+ /**
1873
+ * Check if a handler result is a pre-formatted MCP result.
1874
+ * Pre-formatted results have a `content` array and are passed through
1875
+ * without re-wrapping. This supports proxy/gateway patterns.
1876
+ */
1877
+ // deno-lint-ignore no-explicit-any
1878
+ private isPreformattedResult(result: unknown): result is { content: Array<{ type: string; text: string }>; _meta?: Record<string, unknown> } {
1879
+ if (!result || typeof result !== "object") return false;
1880
+ const obj = result as Record<string, unknown>;
1881
+ return Array.isArray(obj.content) &&
1882
+ obj.content.length > 0 &&
1883
+ typeof obj.content[0] === "object" &&
1884
+ obj.content[0] !== null &&
1885
+ "type" in obj.content[0] &&
1886
+ "text" in obj.content[0];
1887
+ }
1888
+
1889
+ /**
1890
+ * Log message using custom logger or stderr
1891
+ */
1892
+ private log(msg: string): void {
1893
+ if (this.options.logger) {
1894
+ this.options.logger(msg);
1895
+ } else {
1896
+ console.error(`[${this.options.name}] ${msg}`);
1897
+ }
1898
+ }
1899
+ }