@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.
- package/mod.ts +161 -0
- package/package.json +32 -0
- package/src/auth/config.ts +229 -0
- package/src/auth/jwt-provider.ts +175 -0
- package/src/auth/middleware.ts +170 -0
- package/src/auth/mod.ts +44 -0
- package/src/auth/presets.ts +129 -0
- package/src/auth/provider.ts +47 -0
- package/src/auth/scope-middleware.ts +59 -0
- package/src/auth/types.ts +69 -0
- package/src/concurrency/rate-limiter.ts +190 -0
- package/src/concurrency/request-queue.ts +140 -0
- package/src/concurrent-server.ts +1899 -0
- package/src/middleware/backpressure.ts +36 -0
- package/src/middleware/mod.ts +21 -0
- package/src/middleware/rate-limit.ts +45 -0
- package/src/middleware/runner.ts +63 -0
- package/src/middleware/types.ts +60 -0
- package/src/middleware/validation.ts +28 -0
- package/src/observability/metrics.ts +378 -0
- package/src/observability/mod.ts +20 -0
- package/src/observability/otel.ts +109 -0
- package/src/runtime/runtime.ts +220 -0
- package/src/runtime/types.ts +90 -0
- package/src/sampling/sampling-bridge.ts +191 -0
- package/src/security/channel-hmac.ts +140 -0
- package/src/security/csp.ts +87 -0
- package/src/security/message-signer.ts +223 -0
- package/src/types.ts +478 -0
- package/src/validation/schema-validator.ts +238 -0
|
@@ -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
|
+
}
|