@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.
@@ -1,21 +1,23 @@
1
1
  /**
2
- * Concurrent MCP Server Framework
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
- * concurrency features.
8
+ * middleware, auth, and observability features.
9
9
  *
10
- * @module lib/server/concurrent-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
- ConcurrentServerOptions,
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 { resolveViewerDistPath, discoverViewers } from "./ui/viewer-utils.js";
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
- * ConcurrentMCPServer provides a high-performance MCP server
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 ConcurrentMCPServer({
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 ConcurrentMCPServer {
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: ConcurrentServerOptions;
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: ConcurrentServerOptions) {
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(ReadResourceRequestSchema, async (request) => {
302
- const uri = request.params.uri;
303
- const info = this.resources.get(uri);
304
- if (!info) {
305
- throw new Error(`Resource not found: ${uri}`);
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
- try {
309
- const content = await info.handler(new URL(uri));
310
- const finalContent = this.applyResourceCsp(content);
311
- return { contents: [finalContent] };
312
- } catch (error) {
313
- this.log(
314
- `[ERROR] Resource handler failed for ${uri}: ${
315
- error instanceof Error ? error.message : String(error)
316
- }`,
317
- );
318
- throw error;
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(CallToolRequestSchema, async (request) => {
351
- const toolName = request.params.name;
352
- const args = request.params.arguments || {};
353
-
354
- try {
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
- // If handler returns a pre-formatted MCP result (has content array),
358
- // pass it through without re-wrapping. This supports proxy/gateway
359
- // patterns where the handler builds the complete response.
360
- if (this.isPreformattedResult(result)) {
361
- return result;
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
- // Format response according to MCP protocol
365
- const tool = this.tools.get(toolName);
366
- const response: {
367
- content: Array<{ type: "text"; text: string }>;
368
- _meta?: Record<string, unknown>;
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
- "[ConcurrentMCPServer] Cannot register tools after server started. " +
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
- "[ConcurrentMCPServer] Cannot register tools after server started. " +
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(`Unregistered tool: ${toolName} (remaining: ${this.tools.size})`);
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
- "[ConcurrentMCPServer] Cannot add middleware after server started. " +
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
- "[ConcurrentMCPServer] Pipeline not built. Call start() or startHttp() first.",
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
- `[ConcurrentMCPServer] Resource already registered: ${resource.uri}`,
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
- `[ConcurrentMCPServer] Missing handlers for resources:\n` +
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
- `[ConcurrentMCPServer] Resources already registered:\n` +
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
- `[ConcurrentMCPServer] Handler disappeared for ${resource.uri}`,
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("[ConcurrentMCPServer] registerViewers: prefix is required");
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(config.moduleUrl, viewerName, config.exists);
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
- return { uri: resourceUri, mimeType: MCP_APP_MIME_TYPE, text: html };
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(`Registered ${registered.length} viewer(s): ${registered.join(", ")}`);
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 = ConcurrentMCPServer.SESSION_TTL_MS +
916
- ConcurrentMCPServer.SESSION_GRACE_PERIOD_MS;
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 ConcurrentMCPServer({ name: "my-server", version: "1.0.0" });
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
- "[ConcurrentMCPServer] HTTP auth is required (requireAuth=true) but no auth provider is configured.",
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 >= ConcurrentMCPServer.MAX_SESSIONS) {
1436
+ if (this.sessions.size >= McpApp.MAX_SESSIONS) {
1416
1437
  this.cleanupSessions();
1417
- if (this.sessions.size >= ConcurrentMCPServer.MAX_SESSIONS) {
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
- this.log(
1525
- `Error executing tool ${toolName}: ${
1526
- error instanceof Error ? error.message : String(error)
1527
- }`,
1528
- );
1529
- const errorMessage = error instanceof Error
1530
- ? error.message
1531
- : "Tool execution failed";
1532
- const errorCode = errorMessage.startsWith("Unknown tool")
1533
- ? -32602
1534
- : errorMessage.startsWith("Rate limit")
1535
- ? -32000
1536
- : -32603;
1537
- return c.json({
1538
- jsonrpc: "2.0",
1539
- id,
1540
- error: { code: errorCode, message: errorMessage },
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
- ConcurrentMCPServer.SESSION_CLEANUP_INTERVAL_MS,
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(result: unknown): result is { content: Array<{ type: string; text: string }>; _meta?: Record<string, unknown> } {
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() */