@casys/mcp-server 0.11.0 → 0.13.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/README.md +37 -23
- package/mod.ts +47 -16
- package/package.json +1 -1
- package/src/auth/mod.ts +9 -1
- package/src/auth/multi-tenant-middleware.ts +236 -0
- package/src/auth/types.ts +12 -1
- package/src/inspector/launcher.ts +6 -2
- package/src/{concurrent-server.ts → mcp-app.ts} +381 -144
- package/src/middleware/mod.ts +1 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/types.ts +1 -1
- package/src/observability/metrics.ts +1 -1
- package/src/security/csp.ts +3 -1
- package/src/types.ts +100 -3
- package/src/ui/viewer-utils.ts +7 -1
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* McpApp — Hono-style framework for MCP servers
|
|
3
3
|
*
|
|
4
4
|
* High-performance MCP server with built-in concurrency control,
|
|
5
5
|
* backpressure, and optional sampling support.
|
|
6
6
|
*
|
|
7
7
|
* Wraps the official @modelcontextprotocol/sdk with production-ready
|
|
8
|
-
*
|
|
8
|
+
* middleware, auth, and observability features.
|
|
9
9
|
*
|
|
10
|
-
* @module lib/server/
|
|
10
|
+
* @module lib/server/mcp-app
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
15
|
import {
|
|
16
|
+
type CallToolRequest,
|
|
16
17
|
CallToolRequestSchema,
|
|
17
|
-
ListToolsRequestSchema,
|
|
18
18
|
ListResourcesRequestSchema,
|
|
19
|
+
ListToolsRequestSchema,
|
|
20
|
+
type ReadResourceRequest,
|
|
19
21
|
ReadResourceRequestSchema,
|
|
20
22
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
21
23
|
import { Hono } from "hono";
|
|
@@ -45,18 +47,20 @@ import { createScopeMiddleware } from "./auth/scope-middleware.js";
|
|
|
45
47
|
import { createAuthProviderFromConfig, loadAuthConfig } from "./auth/config.js";
|
|
46
48
|
import type { AuthProvider } from "./auth/provider.js";
|
|
47
49
|
import type {
|
|
48
|
-
|
|
50
|
+
FetchHandler,
|
|
49
51
|
HttpRateLimitContext,
|
|
50
52
|
HttpServerOptions,
|
|
53
|
+
McpAppOptions,
|
|
51
54
|
MCPResource,
|
|
52
55
|
MCPTool,
|
|
53
56
|
QueueMetrics,
|
|
54
57
|
ResourceContent,
|
|
55
58
|
ResourceHandler,
|
|
59
|
+
StructuredToolResult,
|
|
56
60
|
ToolHandler,
|
|
57
61
|
} from "./types.js";
|
|
58
62
|
import { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
|
|
59
|
-
import {
|
|
63
|
+
import { discoverViewers, resolveViewerDistPath } from "./ui/viewer-utils.js";
|
|
60
64
|
import type { DirEntry, DiscoverViewersFS } from "./ui/viewer-utils.js";
|
|
61
65
|
import { buildCspHeader, injectCspMetaTag } from "./security/csp.js";
|
|
62
66
|
import { ServerMetrics } from "./observability/metrics.js";
|
|
@@ -156,7 +160,7 @@ async function readBodyWithLimit(
|
|
|
156
160
|
}
|
|
157
161
|
|
|
158
162
|
/**
|
|
159
|
-
*
|
|
163
|
+
* McpApp provides a high-performance MCP server
|
|
160
164
|
*
|
|
161
165
|
* Features:
|
|
162
166
|
* - Wraps official @modelcontextprotocol/sdk
|
|
@@ -168,7 +172,7 @@ async function readBodyWithLimit(
|
|
|
168
172
|
*
|
|
169
173
|
* @example
|
|
170
174
|
* ```typescript
|
|
171
|
-
* const server = new
|
|
175
|
+
* const server = new McpApp({
|
|
172
176
|
* name: "my-server",
|
|
173
177
|
* version: "1.0.0",
|
|
174
178
|
* maxConcurrent: 5,
|
|
@@ -179,7 +183,7 @@ async function readBodyWithLimit(
|
|
|
179
183
|
* await server.start();
|
|
180
184
|
* ```
|
|
181
185
|
*/
|
|
182
|
-
export class
|
|
186
|
+
export class McpApp {
|
|
183
187
|
private mcpServer: McpServer;
|
|
184
188
|
private requestQueue: RequestQueue;
|
|
185
189
|
private rateLimiter: RateLimiter | null = null;
|
|
@@ -187,7 +191,7 @@ export class ConcurrentMCPServer {
|
|
|
187
191
|
private samplingBridge: SamplingBridge | null = null;
|
|
188
192
|
private tools = new Map<string, ToolWithHandler>();
|
|
189
193
|
private resources = new Map<string, RegisteredResourceInfo>();
|
|
190
|
-
private options:
|
|
194
|
+
private options: McpAppOptions;
|
|
191
195
|
private started = false;
|
|
192
196
|
private resourceHandlersInstalled = false;
|
|
193
197
|
|
|
@@ -221,7 +225,7 @@ export class ConcurrentMCPServer {
|
|
|
221
225
|
windowMs: 60_000,
|
|
222
226
|
});
|
|
223
227
|
|
|
224
|
-
constructor(options:
|
|
228
|
+
constructor(options: McpAppOptions) {
|
|
225
229
|
this.options = options;
|
|
226
230
|
|
|
227
231
|
// Create SDK MCP server
|
|
@@ -234,6 +238,7 @@ export class ConcurrentMCPServer {
|
|
|
234
238
|
capabilities: {
|
|
235
239
|
tools: {},
|
|
236
240
|
},
|
|
241
|
+
instructions: options.instructions,
|
|
237
242
|
},
|
|
238
243
|
);
|
|
239
244
|
|
|
@@ -298,26 +303,29 @@ export class ConcurrentMCPServer {
|
|
|
298
303
|
});
|
|
299
304
|
|
|
300
305
|
// resources/read — serve resource content by URI
|
|
301
|
-
server.setRequestHandler(
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
server.setRequestHandler(
|
|
307
|
+
ReadResourceRequestSchema,
|
|
308
|
+
async (request: ReadResourceRequest) => {
|
|
309
|
+
const uri = request.params.uri;
|
|
310
|
+
const info = this.resources.get(uri);
|
|
311
|
+
if (!info) {
|
|
312
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
313
|
+
}
|
|
307
314
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
try {
|
|
316
|
+
const content = await info.handler(new URL(uri));
|
|
317
|
+
const finalContent = this.applyResourceCsp(content);
|
|
318
|
+
return { contents: [finalContent] };
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.log(
|
|
321
|
+
`[ERROR] Resource handler failed for ${uri}: ${
|
|
322
|
+
error instanceof Error ? error.message : String(error)
|
|
323
|
+
}`,
|
|
324
|
+
);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
);
|
|
321
329
|
|
|
322
330
|
this.resourceHandlersInstalled = true;
|
|
323
331
|
this.log("Resources capability pre-declared (expectResources: true)");
|
|
@@ -336,59 +344,28 @@ export class ConcurrentMCPServer {
|
|
|
336
344
|
|
|
337
345
|
// tools/list handler
|
|
338
346
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
339
|
-
return {
|
|
340
|
-
tools: Array.from(this.tools.values()).map((t) => ({
|
|
341
|
-
name: t.name,
|
|
342
|
-
description: t.description,
|
|
343
|
-
inputSchema: t.inputSchema,
|
|
344
|
-
_meta: t._meta, // Always include, even if undefined (MCP Apps discovery)
|
|
345
|
-
})),
|
|
346
|
-
};
|
|
347
|
+
return { tools: this.buildToolListing() };
|
|
347
348
|
});
|
|
348
349
|
|
|
349
350
|
// tools/call handler (delegates to middleware pipeline)
|
|
350
|
-
server.setRequestHandler(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const result = await this.executeToolCall(toolName, args);
|
|
351
|
+
server.setRequestHandler(
|
|
352
|
+
CallToolRequestSchema,
|
|
353
|
+
async (request: CallToolRequest) => {
|
|
354
|
+
const toolName = request.params.name;
|
|
355
|
+
const args = request.params.arguments || {};
|
|
356
356
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return
|
|
357
|
+
let result: unknown;
|
|
358
|
+
try {
|
|
359
|
+
result = await this.executeToolCall(toolName, args);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return this.handleToolError(error, toolName);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
} = {
|
|
370
|
-
content: [
|
|
371
|
-
{
|
|
372
|
-
type: "text",
|
|
373
|
-
text: typeof result === "string"
|
|
374
|
-
? result
|
|
375
|
-
: JSON.stringify(result, null, 2),
|
|
376
|
-
},
|
|
377
|
-
],
|
|
378
|
-
};
|
|
379
|
-
if (tool?._meta) {
|
|
380
|
-
response._meta = tool._meta as Record<string, unknown>;
|
|
381
|
-
}
|
|
382
|
-
return response;
|
|
383
|
-
} catch (error) {
|
|
384
|
-
this.log(
|
|
385
|
-
`Error executing tool ${request.params.name}: ${
|
|
386
|
-
error instanceof Error ? error.message : String(error)
|
|
387
|
-
}`,
|
|
388
|
-
);
|
|
389
|
-
throw error;
|
|
390
|
-
}
|
|
391
|
-
});
|
|
364
|
+
// Serialization errors are framework bugs, not tool errors —
|
|
365
|
+
// let them propagate
|
|
366
|
+
return this.buildToolCallResult(toolName, result);
|
|
367
|
+
},
|
|
368
|
+
);
|
|
392
369
|
}
|
|
393
370
|
|
|
394
371
|
/**
|
|
@@ -403,7 +380,7 @@ export class ConcurrentMCPServer {
|
|
|
403
380
|
): void {
|
|
404
381
|
if (this.started) {
|
|
405
382
|
throw new Error(
|
|
406
|
-
"[
|
|
383
|
+
"[McpApp] Cannot register tools after server started. " +
|
|
407
384
|
"Call registerTools() before start() or startHttp().",
|
|
408
385
|
);
|
|
409
386
|
}
|
|
@@ -436,7 +413,7 @@ export class ConcurrentMCPServer {
|
|
|
436
413
|
registerTool(tool: MCPTool, handler: ToolHandler): void {
|
|
437
414
|
if (this.started) {
|
|
438
415
|
throw new Error(
|
|
439
|
-
"[
|
|
416
|
+
"[McpApp] Cannot register tools after server started. " +
|
|
440
417
|
"Call registerTool() before start() or startHttp().",
|
|
441
418
|
);
|
|
442
419
|
}
|
|
@@ -478,6 +455,30 @@ export class ConcurrentMCPServer {
|
|
|
478
455
|
this.log(`Live-registered tool: ${tool.name} (total: ${this.tools.size})`);
|
|
479
456
|
}
|
|
480
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Register a tool that is only visible to the MCP App (UI layer),
|
|
460
|
+
* not to the model via tools/list.
|
|
461
|
+
*
|
|
462
|
+
* Equivalent to registerTool() with _meta.ui.visibility: ["app"].
|
|
463
|
+
* The tool is still callable via tools/call if the caller knows its name.
|
|
464
|
+
*
|
|
465
|
+
* @param tool - Tool definition
|
|
466
|
+
* @param handler - Tool handler function
|
|
467
|
+
*/
|
|
468
|
+
registerAppOnlyTool(tool: MCPTool, handler: ToolHandler): void {
|
|
469
|
+
const merged: MCPTool = {
|
|
470
|
+
...tool,
|
|
471
|
+
_meta: {
|
|
472
|
+
...tool._meta,
|
|
473
|
+
ui: {
|
|
474
|
+
...tool._meta?.ui,
|
|
475
|
+
visibility: ["app"],
|
|
476
|
+
},
|
|
477
|
+
} as MCPTool["_meta"],
|
|
478
|
+
};
|
|
479
|
+
this.registerTool(merged, handler);
|
|
480
|
+
}
|
|
481
|
+
|
|
481
482
|
/**
|
|
482
483
|
* Unregister a tool (removes it from tools/list and tools/call).
|
|
483
484
|
*
|
|
@@ -490,7 +491,9 @@ export class ConcurrentMCPServer {
|
|
|
490
491
|
unregisterTool(toolName: string): boolean {
|
|
491
492
|
const deleted = this.tools.delete(toolName);
|
|
492
493
|
if (deleted) {
|
|
493
|
-
this.log(
|
|
494
|
+
this.log(
|
|
495
|
+
`Unregistered tool: ${toolName} (remaining: ${this.tools.size})`,
|
|
496
|
+
);
|
|
494
497
|
}
|
|
495
498
|
return deleted;
|
|
496
499
|
}
|
|
@@ -522,7 +525,7 @@ export class ConcurrentMCPServer {
|
|
|
522
525
|
use(middleware: Middleware): this {
|
|
523
526
|
if (this.started) {
|
|
524
527
|
throw new Error(
|
|
525
|
-
"[
|
|
528
|
+
"[McpApp] Cannot add middleware after server started. " +
|
|
526
529
|
"Call use() before start() or startHttp().",
|
|
527
530
|
);
|
|
528
531
|
}
|
|
@@ -602,7 +605,7 @@ export class ConcurrentMCPServer {
|
|
|
602
605
|
): Promise<MiddlewareResult> {
|
|
603
606
|
if (!this.middlewareRunner) {
|
|
604
607
|
throw new Error(
|
|
605
|
-
"[
|
|
608
|
+
"[McpApp] Pipeline not built. Call start() or startHttp() first.",
|
|
606
609
|
);
|
|
607
610
|
}
|
|
608
611
|
|
|
@@ -707,7 +710,7 @@ export class ConcurrentMCPServer {
|
|
|
707
710
|
// Check for duplicate
|
|
708
711
|
if (this.resources.has(resource.uri)) {
|
|
709
712
|
throw new Error(
|
|
710
|
-
`[
|
|
713
|
+
`[McpApp] Resource already registered: ${resource.uri}`,
|
|
711
714
|
);
|
|
712
715
|
}
|
|
713
716
|
|
|
@@ -769,7 +772,7 @@ export class ConcurrentMCPServer {
|
|
|
769
772
|
|
|
770
773
|
if (missingHandlers.length > 0) {
|
|
771
774
|
throw new Error(
|
|
772
|
-
`[
|
|
775
|
+
`[McpApp] Missing handlers for resources:\n` +
|
|
773
776
|
missingHandlers.map((uri) => ` - ${uri}`).join("\n"),
|
|
774
777
|
);
|
|
775
778
|
}
|
|
@@ -784,7 +787,7 @@ export class ConcurrentMCPServer {
|
|
|
784
787
|
|
|
785
788
|
if (duplicateUris.length > 0) {
|
|
786
789
|
throw new Error(
|
|
787
|
-
`[
|
|
790
|
+
`[McpApp] Resources already registered:\n` +
|
|
788
791
|
duplicateUris.map((uri) => ` - ${uri}`).join("\n"),
|
|
789
792
|
);
|
|
790
793
|
}
|
|
@@ -795,7 +798,7 @@ export class ConcurrentMCPServer {
|
|
|
795
798
|
if (!handler) {
|
|
796
799
|
// Should never happen after validation, but defensive check
|
|
797
800
|
throw new Error(
|
|
798
|
-
`[
|
|
801
|
+
`[McpApp] Handler disappeared for ${resource.uri}`,
|
|
799
802
|
);
|
|
800
803
|
}
|
|
801
804
|
this.registerResource(resource, handler);
|
|
@@ -817,7 +820,9 @@ export class ConcurrentMCPServer {
|
|
|
817
820
|
*/
|
|
818
821
|
registerViewers(config: RegisterViewersConfig): RegisterViewersSummary {
|
|
819
822
|
if (!config.prefix) {
|
|
820
|
-
throw new Error(
|
|
823
|
+
throw new Error(
|
|
824
|
+
"[McpApp] registerViewers: prefix is required",
|
|
825
|
+
);
|
|
821
826
|
}
|
|
822
827
|
|
|
823
828
|
// Resolve viewer list: explicit or auto-discovered
|
|
@@ -835,7 +840,11 @@ export class ConcurrentMCPServer {
|
|
|
835
840
|
const skipped: string[] = [];
|
|
836
841
|
|
|
837
842
|
for (const viewerName of viewerNames) {
|
|
838
|
-
const distPath = resolveViewerDistPath(
|
|
843
|
+
const distPath = resolveViewerDistPath(
|
|
844
|
+
config.moduleUrl,
|
|
845
|
+
viewerName,
|
|
846
|
+
config.exists,
|
|
847
|
+
);
|
|
839
848
|
|
|
840
849
|
if (!distPath) {
|
|
841
850
|
this.log(
|
|
@@ -859,7 +868,17 @@ export class ConcurrentMCPServer {
|
|
|
859
868
|
},
|
|
860
869
|
async () => {
|
|
861
870
|
const html = await Promise.resolve(readFile(currentDistPath));
|
|
862
|
-
|
|
871
|
+
const content: import("./types.js").ResourceContent = {
|
|
872
|
+
uri: resourceUri,
|
|
873
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
874
|
+
text: html,
|
|
875
|
+
};
|
|
876
|
+
if (config.csp) {
|
|
877
|
+
(content as unknown as Record<string, unknown>)._meta = {
|
|
878
|
+
ui: { csp: config.csp },
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
return content;
|
|
863
882
|
},
|
|
864
883
|
);
|
|
865
884
|
|
|
@@ -867,7 +886,9 @@ export class ConcurrentMCPServer {
|
|
|
867
886
|
}
|
|
868
887
|
|
|
869
888
|
if (registered.length > 0) {
|
|
870
|
-
this.log(
|
|
889
|
+
this.log(
|
|
890
|
+
`Registered ${registered.length} viewer(s): ${registered.join(", ")}`,
|
|
891
|
+
);
|
|
871
892
|
}
|
|
872
893
|
|
|
873
894
|
return { registered, skipped };
|
|
@@ -912,8 +933,8 @@ export class ConcurrentMCPServer {
|
|
|
912
933
|
*/
|
|
913
934
|
private cleanupSessions(): void {
|
|
914
935
|
const now = Date.now();
|
|
915
|
-
const ttlWithGrace =
|
|
916
|
-
|
|
936
|
+
const ttlWithGrace = McpApp.SESSION_TTL_MS +
|
|
937
|
+
McpApp.SESSION_GRACE_PERIOD_MS;
|
|
917
938
|
let cleaned = 0;
|
|
918
939
|
for (const [sessionId, session] of this.sessions) {
|
|
919
940
|
if (now - session.lastActivity > ttlWithGrace) {
|
|
@@ -999,7 +1020,7 @@ export class ConcurrentMCPServer {
|
|
|
999
1020
|
*
|
|
1000
1021
|
* @example
|
|
1001
1022
|
* ```typescript
|
|
1002
|
-
* const server = new
|
|
1023
|
+
* const server = new McpApp({ name: "my-server", version: "1.0.0" });
|
|
1003
1024
|
* server.registerTools(tools, handlers);
|
|
1004
1025
|
* server.registerResource(resource, handler);
|
|
1005
1026
|
*
|
|
@@ -1039,7 +1060,7 @@ export class ConcurrentMCPServer {
|
|
|
1039
1060
|
const requireAuth = options.requireAuth ?? false;
|
|
1040
1061
|
if (requireAuth && !this.authProvider) {
|
|
1041
1062
|
throw new Error(
|
|
1042
|
-
"[
|
|
1063
|
+
"[McpApp] HTTP auth is required (requireAuth=true) but no auth provider is configured.",
|
|
1043
1064
|
);
|
|
1044
1065
|
}
|
|
1045
1066
|
if (!this.authProvider && !requireAuth) {
|
|
@@ -1412,9 +1433,9 @@ export class ConcurrentMCPServer {
|
|
|
1412
1433
|
}
|
|
1413
1434
|
|
|
1414
1435
|
// Guard against session exhaustion
|
|
1415
|
-
if (this.sessions.size >=
|
|
1436
|
+
if (this.sessions.size >= McpApp.MAX_SESSIONS) {
|
|
1416
1437
|
this.cleanupSessions();
|
|
1417
|
-
if (this.sessions.size >=
|
|
1438
|
+
if (this.sessions.size >= McpApp.MAX_SESSIONS) {
|
|
1418
1439
|
return c.json({
|
|
1419
1440
|
jsonrpc: "2.0",
|
|
1420
1441
|
id,
|
|
@@ -1443,6 +1464,9 @@ export class ConcurrentMCPServer {
|
|
|
1443
1464
|
name: this.options.name,
|
|
1444
1465
|
version: this.options.version,
|
|
1445
1466
|
},
|
|
1467
|
+
...(this.options.instructions
|
|
1468
|
+
? { instructions: this.options.instructions }
|
|
1469
|
+
: {}),
|
|
1446
1470
|
},
|
|
1447
1471
|
}),
|
|
1448
1472
|
{
|
|
@@ -1480,29 +1504,10 @@ export class ConcurrentMCPServer {
|
|
|
1480
1504
|
c.req.raw,
|
|
1481
1505
|
reqSessionId,
|
|
1482
1506
|
);
|
|
1483
|
-
|
|
1484
|
-
// Pre-formatted result: pass through as-is
|
|
1485
|
-
if (this.isPreformattedResult(result)) {
|
|
1486
|
-
return c.json({
|
|
1487
|
-
jsonrpc: "2.0",
|
|
1488
|
-
id,
|
|
1489
|
-
result,
|
|
1490
|
-
});
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
const tool = this.tools.get(toolName);
|
|
1494
1507
|
return c.json({
|
|
1495
1508
|
jsonrpc: "2.0",
|
|
1496
1509
|
id,
|
|
1497
|
-
result:
|
|
1498
|
-
content: [{
|
|
1499
|
-
type: "text",
|
|
1500
|
-
text: typeof result === "string"
|
|
1501
|
-
? result
|
|
1502
|
-
: JSON.stringify(result, null, 2),
|
|
1503
|
-
}],
|
|
1504
|
-
...(tool?._meta && { _meta: tool._meta }),
|
|
1505
|
-
},
|
|
1510
|
+
result: this.buildToolCallResult(toolName, result),
|
|
1506
1511
|
});
|
|
1507
1512
|
} catch (error) {
|
|
1508
1513
|
// Handle AuthError with proper HTTP status codes
|
|
@@ -1521,24 +1526,36 @@ export class ConcurrentMCPServer {
|
|
|
1521
1526
|
}
|
|
1522
1527
|
}
|
|
1523
1528
|
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1529
|
+
// Delegate to centralized error handler
|
|
1530
|
+
try {
|
|
1531
|
+
const isErrorResult = this.handleToolError(error, toolName);
|
|
1532
|
+
return c.json({
|
|
1533
|
+
jsonrpc: "2.0",
|
|
1534
|
+
id,
|
|
1535
|
+
result: isErrorResult,
|
|
1536
|
+
});
|
|
1537
|
+
} catch (rethrown) {
|
|
1538
|
+
this.log(
|
|
1539
|
+
`Error executing tool ${toolName}: ${
|
|
1540
|
+
rethrown instanceof Error
|
|
1541
|
+
? rethrown.message
|
|
1542
|
+
: String(rethrown)
|
|
1543
|
+
}`,
|
|
1544
|
+
);
|
|
1545
|
+
const errorMessage = rethrown instanceof Error
|
|
1546
|
+
? rethrown.message
|
|
1547
|
+
: "Tool execution failed";
|
|
1548
|
+
const errorCode = errorMessage.startsWith("Unknown tool")
|
|
1549
|
+
? -32602
|
|
1550
|
+
: errorMessage.startsWith("Rate limit")
|
|
1551
|
+
? -32000
|
|
1552
|
+
: -32603;
|
|
1553
|
+
return c.json({
|
|
1554
|
+
jsonrpc: "2.0",
|
|
1555
|
+
id,
|
|
1556
|
+
error: { code: errorCode, message: errorMessage },
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1542
1559
|
}
|
|
1543
1560
|
}
|
|
1544
1561
|
|
|
@@ -1552,14 +1569,7 @@ export class ConcurrentMCPServer {
|
|
|
1552
1569
|
return c.json({
|
|
1553
1570
|
jsonrpc: "2.0",
|
|
1554
1571
|
id,
|
|
1555
|
-
result: {
|
|
1556
|
-
tools: Array.from(this.tools.values()).map((t) => ({
|
|
1557
|
-
name: t.name,
|
|
1558
|
-
description: t.description,
|
|
1559
|
-
inputSchema: t.inputSchema,
|
|
1560
|
-
_meta: t._meta,
|
|
1561
|
-
})),
|
|
1562
|
-
},
|
|
1572
|
+
result: { tools: this.buildToolListing() },
|
|
1563
1573
|
});
|
|
1564
1574
|
}
|
|
1565
1575
|
|
|
@@ -1679,6 +1689,37 @@ export class ConcurrentMCPServer {
|
|
|
1679
1689
|
// deno-lint-ignore no-explicit-any
|
|
1680
1690
|
app.post("/", handleMcpPost as any);
|
|
1681
1691
|
|
|
1692
|
+
// Embedded mode: skip serve(), surface the Hono fetch handler to the
|
|
1693
|
+
// caller and let them mount it inside their own framework (Fresh, Hono,
|
|
1694
|
+
// Express, etc.). The session cleanup timer + post-init still runs so
|
|
1695
|
+
// SSE clients and sessions are managed identically to the serve() path.
|
|
1696
|
+
if (options.embedded) {
|
|
1697
|
+
if (!options.embeddedHandlerCallback) {
|
|
1698
|
+
throw new Error(
|
|
1699
|
+
"[McpApp] embedded=true requires embeddedHandlerCallback",
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
// deno-lint-ignore no-explicit-any
|
|
1703
|
+
options.embeddedHandlerCallback(app.fetch as any);
|
|
1704
|
+
this.started = true;
|
|
1705
|
+
this.sessionCleanupTimer = setInterval(
|
|
1706
|
+
() => this.cleanupSessions(),
|
|
1707
|
+
McpApp.SESSION_CLEANUP_INTERVAL_MS,
|
|
1708
|
+
);
|
|
1709
|
+
unrefTimer(this.sessionCleanupTimer as unknown as number);
|
|
1710
|
+
this.log(
|
|
1711
|
+
`HTTP handler ready (embedded mode — no port bound, max concurrent: ${
|
|
1712
|
+
this.options.maxConcurrent ?? 10
|
|
1713
|
+
})`,
|
|
1714
|
+
);
|
|
1715
|
+
return {
|
|
1716
|
+
shutdown: async () => {
|
|
1717
|
+
await this.stop();
|
|
1718
|
+
},
|
|
1719
|
+
addr: { hostname: "embedded", port: 0 },
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1682
1723
|
// Start server
|
|
1683
1724
|
this.httpServer = serve(
|
|
1684
1725
|
{
|
|
@@ -1699,7 +1740,7 @@ export class ConcurrentMCPServer {
|
|
|
1699
1740
|
// Start session cleanup timer (prevents unbounded memory growth)
|
|
1700
1741
|
this.sessionCleanupTimer = setInterval(
|
|
1701
1742
|
() => this.cleanupSessions(),
|
|
1702
|
-
|
|
1743
|
+
McpApp.SESSION_CLEANUP_INTERVAL_MS,
|
|
1703
1744
|
);
|
|
1704
1745
|
// Don't block Deno from exiting because of cleanup timer
|
|
1705
1746
|
unrefTimer(this.sessionCleanupTimer as unknown as number);
|
|
@@ -1730,6 +1771,67 @@ export class ConcurrentMCPServer {
|
|
|
1730
1771
|
};
|
|
1731
1772
|
}
|
|
1732
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* Build the HTTP middleware stack and return its fetch handler without
|
|
1776
|
+
* binding a port. Use this when you want to mount the MCP HTTP layer
|
|
1777
|
+
* inside another HTTP framework (Fresh, Hono, Express, Cloudflare Workers,
|
|
1778
|
+
* etc.) instead of giving up port ownership to {@link startHttp}.
|
|
1779
|
+
*
|
|
1780
|
+
* The returned handler accepts a Web Standard {@link Request} and returns
|
|
1781
|
+
* a Web Standard {@link Response}. It exposes the same routes as
|
|
1782
|
+
* {@link startHttp}: `POST /mcp`, `GET /mcp` (SSE), `GET /health`,
|
|
1783
|
+
* `GET /metrics`, and `GET /.well-known/oauth-protected-resource`.
|
|
1784
|
+
*
|
|
1785
|
+
* Auth, multi-tenant middleware, scope checks, and rate limiting are all
|
|
1786
|
+
* applied identically. The session cleanup timer and OTel hooks are
|
|
1787
|
+
* started, so the server is fully live after this returns — just without
|
|
1788
|
+
* its own listening socket.
|
|
1789
|
+
*
|
|
1790
|
+
* Multi-tenant SaaS pattern: cache one `McpApp` per tenant
|
|
1791
|
+
* and call `getFetchHandler()` once per server, then dispatch each
|
|
1792
|
+
* inbound request to the right cached handler from your framework's
|
|
1793
|
+
* routing layer.
|
|
1794
|
+
*
|
|
1795
|
+
* @example
|
|
1796
|
+
* ```typescript
|
|
1797
|
+
* // In a Fresh route at routes/mcp/[...path].tsx
|
|
1798
|
+
* const server = new McpApp({ name: "my-mcp", version: "1.0.0" });
|
|
1799
|
+
* server.registerTools(tools, handlers);
|
|
1800
|
+
* const handler = await server.getFetchHandler({
|
|
1801
|
+
* requireAuth: true,
|
|
1802
|
+
* auth: { provider: myAuthProvider },
|
|
1803
|
+
* });
|
|
1804
|
+
* // Later, in your route handler:
|
|
1805
|
+
* return await handler(ctx.req);
|
|
1806
|
+
* ```
|
|
1807
|
+
*
|
|
1808
|
+
* @param options - Same as {@link startHttp}, minus `port`/`hostname`/`onListen`.
|
|
1809
|
+
* @returns A Web Standard fetch handler.
|
|
1810
|
+
*/
|
|
1811
|
+
async getFetchHandler(
|
|
1812
|
+
options: Omit<HttpServerOptions, "port" | "hostname" | "onListen"> = {},
|
|
1813
|
+
): Promise<FetchHandler> {
|
|
1814
|
+
let captured: FetchHandler | null = null;
|
|
1815
|
+
await this.startHttp({
|
|
1816
|
+
// port/hostname are unused in embedded mode but the type requires them.
|
|
1817
|
+
// Pass sentinel values that would never bind even if used.
|
|
1818
|
+
port: 0,
|
|
1819
|
+
...options,
|
|
1820
|
+
embedded: true,
|
|
1821
|
+
embeddedHandlerCallback: (handler) => {
|
|
1822
|
+
captured = handler;
|
|
1823
|
+
},
|
|
1824
|
+
});
|
|
1825
|
+
if (!captured) {
|
|
1826
|
+
// Defensive: startHttp should always invoke the callback synchronously
|
|
1827
|
+
// before returning. If it didn't, something is structurally wrong.
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
"[McpApp] getFetchHandler: embedded callback was not invoked",
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
return captured;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1733
1835
|
/**
|
|
1734
1836
|
* Send a JSON-RPC message to all SSE clients in a session
|
|
1735
1837
|
* Used for server-initiated notifications and requests
|
|
@@ -1964,7 +2066,12 @@ export class ConcurrentMCPServer {
|
|
|
1964
2066
|
* without re-wrapping. This supports proxy/gateway patterns.
|
|
1965
2067
|
*/
|
|
1966
2068
|
// deno-lint-ignore no-explicit-any
|
|
1967
|
-
private isPreformattedResult(
|
|
2069
|
+
private isPreformattedResult(
|
|
2070
|
+
result: unknown,
|
|
2071
|
+
): result is {
|
|
2072
|
+
content: Array<{ type: string; text: string }>;
|
|
2073
|
+
_meta?: Record<string, unknown>;
|
|
2074
|
+
} {
|
|
1968
2075
|
if (!result || typeof result !== "object") return false;
|
|
1969
2076
|
const obj = result as Record<string, unknown>;
|
|
1970
2077
|
return Array.isArray(obj.content) &&
|
|
@@ -1975,6 +2082,129 @@ export class ConcurrentMCPServer {
|
|
|
1975
2082
|
"text" in obj.content[0];
|
|
1976
2083
|
}
|
|
1977
2084
|
|
|
2085
|
+
/**
|
|
2086
|
+
* Build the tools/list response, filtering out app-only tools
|
|
2087
|
+
* and passing through outputSchema/annotations when defined.
|
|
2088
|
+
*/
|
|
2089
|
+
private buildToolListing(): Array<Record<string, unknown>> {
|
|
2090
|
+
return Array.from(this.tools.values())
|
|
2091
|
+
.filter((t) => {
|
|
2092
|
+
const vis = t._meta?.ui?.visibility;
|
|
2093
|
+
if (vis !== undefined && !vis.includes("model")) return false;
|
|
2094
|
+
return true;
|
|
2095
|
+
})
|
|
2096
|
+
.map((t) => {
|
|
2097
|
+
const entry: Record<string, unknown> = {
|
|
2098
|
+
name: t.name,
|
|
2099
|
+
description: t.description,
|
|
2100
|
+
inputSchema: t.inputSchema,
|
|
2101
|
+
_meta: t._meta,
|
|
2102
|
+
};
|
|
2103
|
+
if (t.outputSchema !== undefined) entry.outputSchema = t.outputSchema;
|
|
2104
|
+
if (t.annotations !== undefined) entry.annotations = t.annotations;
|
|
2105
|
+
return entry;
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* Check if a handler result is a StructuredToolResult
|
|
2111
|
+
* (has content as string + structuredContent as object).
|
|
2112
|
+
*/
|
|
2113
|
+
private isStructuredToolResult(
|
|
2114
|
+
result: unknown,
|
|
2115
|
+
): result is StructuredToolResult {
|
|
2116
|
+
if (!result || typeof result !== "object") return false;
|
|
2117
|
+
const obj = result as Record<string, unknown>;
|
|
2118
|
+
return (
|
|
2119
|
+
typeof obj.content === "string" &&
|
|
2120
|
+
obj.structuredContent !== null &&
|
|
2121
|
+
obj.structuredContent !== undefined &&
|
|
2122
|
+
typeof obj.structuredContent === "object" &&
|
|
2123
|
+
!Array.isArray(obj.structuredContent)
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Build a CallToolResult from the handler's return value.
|
|
2129
|
+
* Priority: preformatted > structuredToolResult > plain value.
|
|
2130
|
+
*/
|
|
2131
|
+
private buildToolCallResult(
|
|
2132
|
+
toolName: string,
|
|
2133
|
+
result: unknown,
|
|
2134
|
+
): Record<string, unknown> {
|
|
2135
|
+
// Proxy/gateway pattern — pass through as-is
|
|
2136
|
+
if (this.isPreformattedResult(result)) {
|
|
2137
|
+
return result as Record<string, unknown>;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const tool = this.tools.get(toolName);
|
|
2141
|
+
|
|
2142
|
+
// StructuredToolResult: separate content (for LLM) and structuredContent (for viewer)
|
|
2143
|
+
if (this.isStructuredToolResult(result)) {
|
|
2144
|
+
const r: Record<string, unknown> = {
|
|
2145
|
+
content: [{ type: "text", text: result.content }],
|
|
2146
|
+
structuredContent: result.structuredContent,
|
|
2147
|
+
};
|
|
2148
|
+
if (tool?._meta) r._meta = tool._meta;
|
|
2149
|
+
return r;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Plain value: JSON-stringify into content[0].text
|
|
2153
|
+
const r: Record<string, unknown> = {
|
|
2154
|
+
content: [{
|
|
2155
|
+
type: "text",
|
|
2156
|
+
text: typeof result === "string"
|
|
2157
|
+
? result
|
|
2158
|
+
: JSON.stringify(result, null, 2),
|
|
2159
|
+
}],
|
|
2160
|
+
};
|
|
2161
|
+
if (tool?._meta) r._meta = tool._meta;
|
|
2162
|
+
return r;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Handle a tool execution error using the configured toolErrorMapper.
|
|
2167
|
+
* Returns an isError result if the mapper handles it, otherwise rethrows.
|
|
2168
|
+
*/
|
|
2169
|
+
private handleToolError(
|
|
2170
|
+
error: unknown,
|
|
2171
|
+
toolName: string,
|
|
2172
|
+
): Record<string, unknown> {
|
|
2173
|
+
const mapper = this.options.toolErrorMapper;
|
|
2174
|
+
if (mapper) {
|
|
2175
|
+
let msg: string | null;
|
|
2176
|
+
try {
|
|
2177
|
+
msg = mapper(error, toolName);
|
|
2178
|
+
} catch (mapperError) {
|
|
2179
|
+
this.log(
|
|
2180
|
+
`toolErrorMapper threw for tool ${toolName}: ${
|
|
2181
|
+
mapperError instanceof Error
|
|
2182
|
+
? mapperError.message
|
|
2183
|
+
: String(mapperError)
|
|
2184
|
+
} (original error: ${
|
|
2185
|
+
error instanceof Error ? error.message : String(error)
|
|
2186
|
+
})`,
|
|
2187
|
+
);
|
|
2188
|
+
// Fall through to rethrow original error
|
|
2189
|
+
msg = null;
|
|
2190
|
+
}
|
|
2191
|
+
if (msg !== null) {
|
|
2192
|
+
this.log(`Tool ${toolName} returned business error: ${msg}`);
|
|
2193
|
+
return {
|
|
2194
|
+
content: [{ type: "text", text: msg }],
|
|
2195
|
+
isError: true,
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
// No mapper or mapper returned null → rethrow
|
|
2200
|
+
this.log(
|
|
2201
|
+
`Error executing tool ${toolName}: ${
|
|
2202
|
+
error instanceof Error ? error.message : String(error)
|
|
2203
|
+
}`,
|
|
2204
|
+
);
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
1978
2208
|
/**
|
|
1979
2209
|
* Log message using custom logger or stderr
|
|
1980
2210
|
*/
|
|
@@ -2007,6 +2237,13 @@ export interface RegisterViewersConfig {
|
|
|
2007
2237
|
} & DiscoverViewersFS;
|
|
2008
2238
|
/** Custom function to generate human-readable names. Default: kebab-to-Title. */
|
|
2009
2239
|
humanName?: (viewerName: string) => string;
|
|
2240
|
+
/** MCP Apps CSP — declares external domains the viewer needs (tiles, APIs, CDNs).
|
|
2241
|
+
* Uses McpUiCsp from @casys/mcp-compose (resourceDomains, connectDomains). */
|
|
2242
|
+
csp?: {
|
|
2243
|
+
resourceDomains?: string[];
|
|
2244
|
+
connectDomains?: string[];
|
|
2245
|
+
frameDomains?: string[];
|
|
2246
|
+
};
|
|
2010
2247
|
}
|
|
2011
2248
|
|
|
2012
2249
|
/** Summary returned by registerViewers() */
|