@casys/mcp-server 0.11.0 → 0.12.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 CHANGED
@@ -71,6 +71,7 @@ export type {
71
71
  MCPTool,
72
72
  MCPToolMeta,
73
73
  McpUiToolMeta,
74
+ ToolAnnotations,
74
75
  QueueMetrics,
75
76
  RateLimitContext,
76
77
  RateLimitOptions,
@@ -79,6 +80,8 @@ export type {
79
80
  SamplingClient,
80
81
  SamplingParams,
81
82
  SamplingResult,
83
+ StructuredToolResult,
84
+ ToolErrorMapper,
82
85
  ToolHandler,
83
86
  } from "./src/types.js";
84
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -53,6 +53,7 @@ import type {
53
53
  QueueMetrics,
54
54
  ResourceContent,
55
55
  ResourceHandler,
56
+ StructuredToolResult,
56
57
  ToolHandler,
57
58
  } from "./types.js";
58
59
  import { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
@@ -234,6 +235,7 @@ export class ConcurrentMCPServer {
234
235
  capabilities: {
235
236
  tools: {},
236
237
  },
238
+ instructions: options.instructions,
237
239
  },
238
240
  );
239
241
 
@@ -336,14 +338,7 @@ export class ConcurrentMCPServer {
336
338
 
337
339
  // tools/list handler
338
340
  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
- };
341
+ return { tools: this.buildToolListing() };
347
342
  });
348
343
 
349
344
  // tools/call handler (delegates to middleware pipeline)
@@ -351,43 +346,15 @@ export class ConcurrentMCPServer {
351
346
  const toolName = request.params.name;
352
347
  const args = request.params.arguments || {};
353
348
 
349
+ let result: unknown;
354
350
  try {
355
- const result = await this.executeToolCall(toolName, args);
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;
362
- }
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;
351
+ result = await this.executeToolCall(toolName, args);
383
352
  } 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;
353
+ return this.handleToolError(error, toolName);
390
354
  }
355
+
356
+ // Serialization errors are framework bugs, not tool errors — let them propagate
357
+ return this.buildToolCallResult(toolName, result);
391
358
  });
392
359
  }
393
360
 
@@ -478,6 +445,30 @@ export class ConcurrentMCPServer {
478
445
  this.log(`Live-registered tool: ${tool.name} (total: ${this.tools.size})`);
479
446
  }
480
447
 
448
+ /**
449
+ * Register a tool that is only visible to the MCP App (UI layer),
450
+ * not to the model via tools/list.
451
+ *
452
+ * Equivalent to registerTool() with _meta.ui.visibility: ["app"].
453
+ * The tool is still callable via tools/call if the caller knows its name.
454
+ *
455
+ * @param tool - Tool definition
456
+ * @param handler - Tool handler function
457
+ */
458
+ registerAppOnlyTool(tool: MCPTool, handler: ToolHandler): void {
459
+ const merged: MCPTool = {
460
+ ...tool,
461
+ _meta: {
462
+ ...tool._meta,
463
+ ui: {
464
+ ...tool._meta?.ui,
465
+ visibility: ["app"],
466
+ },
467
+ } as MCPTool["_meta"],
468
+ };
469
+ this.registerTool(merged, handler);
470
+ }
471
+
481
472
  /**
482
473
  * Unregister a tool (removes it from tools/list and tools/call).
483
474
  *
@@ -859,7 +850,15 @@ export class ConcurrentMCPServer {
859
850
  },
860
851
  async () => {
861
852
  const html = await Promise.resolve(readFile(currentDistPath));
862
- return { uri: resourceUri, mimeType: MCP_APP_MIME_TYPE, text: html };
853
+ const content: import("./types.js").ResourceContent = {
854
+ uri: resourceUri,
855
+ mimeType: MCP_APP_MIME_TYPE,
856
+ text: html,
857
+ };
858
+ if (config.csp) {
859
+ (content as unknown as Record<string, unknown>)._meta = { ui: { csp: config.csp } };
860
+ }
861
+ return content;
863
862
  },
864
863
  );
865
864
 
@@ -1443,6 +1442,7 @@ export class ConcurrentMCPServer {
1443
1442
  name: this.options.name,
1444
1443
  version: this.options.version,
1445
1444
  },
1445
+ ...(this.options.instructions ? { instructions: this.options.instructions } : {}),
1446
1446
  },
1447
1447
  }),
1448
1448
  {
@@ -1480,29 +1480,10 @@ export class ConcurrentMCPServer {
1480
1480
  c.req.raw,
1481
1481
  reqSessionId,
1482
1482
  );
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
1483
  return c.json({
1495
1484
  jsonrpc: "2.0",
1496
1485
  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
- },
1486
+ result: this.buildToolCallResult(toolName, result),
1506
1487
  });
1507
1488
  } catch (error) {
1508
1489
  // Handle AuthError with proper HTTP status codes
@@ -1521,24 +1502,34 @@ export class ConcurrentMCPServer {
1521
1502
  }
1522
1503
  }
1523
1504
 
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
- });
1505
+ // Delegate to centralized error handler
1506
+ try {
1507
+ const isErrorResult = this.handleToolError(error, toolName);
1508
+ return c.json({
1509
+ jsonrpc: "2.0",
1510
+ id,
1511
+ result: isErrorResult,
1512
+ });
1513
+ } catch (rethrown) {
1514
+ this.log(
1515
+ `Error executing tool ${toolName}: ${
1516
+ rethrown instanceof Error ? rethrown.message : String(rethrown)
1517
+ }`,
1518
+ );
1519
+ const errorMessage = rethrown instanceof Error
1520
+ ? rethrown.message
1521
+ : "Tool execution failed";
1522
+ const errorCode = errorMessage.startsWith("Unknown tool")
1523
+ ? -32602
1524
+ : errorMessage.startsWith("Rate limit")
1525
+ ? -32000
1526
+ : -32603;
1527
+ return c.json({
1528
+ jsonrpc: "2.0",
1529
+ id,
1530
+ error: { code: errorCode, message: errorMessage },
1531
+ });
1532
+ }
1542
1533
  }
1543
1534
  }
1544
1535
 
@@ -1552,14 +1543,7 @@ export class ConcurrentMCPServer {
1552
1543
  return c.json({
1553
1544
  jsonrpc: "2.0",
1554
1545
  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
- },
1546
+ result: { tools: this.buildToolListing() },
1563
1547
  });
1564
1548
  }
1565
1549
 
@@ -1975,6 +1959,127 @@ export class ConcurrentMCPServer {
1975
1959
  "text" in obj.content[0];
1976
1960
  }
1977
1961
 
1962
+ /**
1963
+ * Build the tools/list response, filtering out app-only tools
1964
+ * and passing through outputSchema/annotations when defined.
1965
+ */
1966
+ private buildToolListing(): Array<Record<string, unknown>> {
1967
+ return Array.from(this.tools.values())
1968
+ .filter((t) => {
1969
+ const vis = t._meta?.ui?.visibility;
1970
+ if (vis !== undefined && !vis.includes("model")) return false;
1971
+ return true;
1972
+ })
1973
+ .map((t) => {
1974
+ const entry: Record<string, unknown> = {
1975
+ name: t.name,
1976
+ description: t.description,
1977
+ inputSchema: t.inputSchema,
1978
+ _meta: t._meta,
1979
+ };
1980
+ if (t.outputSchema !== undefined) entry.outputSchema = t.outputSchema;
1981
+ if (t.annotations !== undefined) entry.annotations = t.annotations;
1982
+ return entry;
1983
+ });
1984
+ }
1985
+
1986
+ /**
1987
+ * Check if a handler result is a StructuredToolResult
1988
+ * (has content as string + structuredContent as object).
1989
+ */
1990
+ private isStructuredToolResult(
1991
+ result: unknown,
1992
+ ): result is StructuredToolResult {
1993
+ if (!result || typeof result !== "object") return false;
1994
+ const obj = result as Record<string, unknown>;
1995
+ return (
1996
+ typeof obj.content === "string" &&
1997
+ obj.structuredContent !== null &&
1998
+ obj.structuredContent !== undefined &&
1999
+ typeof obj.structuredContent === "object" &&
2000
+ !Array.isArray(obj.structuredContent)
2001
+ );
2002
+ }
2003
+
2004
+ /**
2005
+ * Build a CallToolResult from the handler's return value.
2006
+ * Priority: preformatted > structuredToolResult > plain value.
2007
+ */
2008
+ private buildToolCallResult(
2009
+ toolName: string,
2010
+ result: unknown,
2011
+ ): Record<string, unknown> {
2012
+ // Proxy/gateway pattern — pass through as-is
2013
+ if (this.isPreformattedResult(result)) {
2014
+ return result as Record<string, unknown>;
2015
+ }
2016
+
2017
+ const tool = this.tools.get(toolName);
2018
+
2019
+ // StructuredToolResult: separate content (for LLM) and structuredContent (for viewer)
2020
+ if (this.isStructuredToolResult(result)) {
2021
+ const r: Record<string, unknown> = {
2022
+ content: [{ type: "text", text: result.content }],
2023
+ structuredContent: result.structuredContent,
2024
+ };
2025
+ if (tool?._meta) r._meta = tool._meta;
2026
+ return r;
2027
+ }
2028
+
2029
+ // Plain value: JSON-stringify into content[0].text
2030
+ const r: Record<string, unknown> = {
2031
+ content: [{
2032
+ type: "text",
2033
+ text: typeof result === "string"
2034
+ ? result
2035
+ : JSON.stringify(result, null, 2),
2036
+ }],
2037
+ };
2038
+ if (tool?._meta) r._meta = tool._meta;
2039
+ return r;
2040
+ }
2041
+
2042
+ /**
2043
+ * Handle a tool execution error using the configured toolErrorMapper.
2044
+ * Returns an isError result if the mapper handles it, otherwise rethrows.
2045
+ */
2046
+ private handleToolError(
2047
+ error: unknown,
2048
+ toolName: string,
2049
+ ): Record<string, unknown> {
2050
+ const mapper = this.options.toolErrorMapper;
2051
+ if (mapper) {
2052
+ let msg: string | null;
2053
+ try {
2054
+ msg = mapper(error, toolName);
2055
+ } catch (mapperError) {
2056
+ this.log(
2057
+ `toolErrorMapper threw for tool ${toolName}: ${
2058
+ mapperError instanceof Error ? mapperError.message : String(mapperError)
2059
+ } (original error: ${
2060
+ error instanceof Error ? error.message : String(error)
2061
+ })`,
2062
+ );
2063
+ // Fall through to rethrow original error
2064
+ msg = null;
2065
+ }
2066
+ if (msg !== null) {
2067
+ this.log(`Tool ${toolName} returned business error: ${msg}`);
2068
+ return {
2069
+ content: [{ type: "text", text: msg }],
2070
+ isError: true,
2071
+ };
2072
+ }
2073
+ }
2074
+ // No mapper or mapper returned null → rethrow
2075
+ this.log(
2076
+ `Error executing tool ${toolName}: ${
2077
+ error instanceof Error ? error.message : String(error)
2078
+ }`,
2079
+ );
2080
+ throw error;
2081
+ }
2082
+
1978
2083
  /**
1979
2084
  * Log message using custom logger or stderr
1980
2085
  */
@@ -2007,6 +2112,9 @@ export interface RegisterViewersConfig {
2007
2112
  } & DiscoverViewersFS;
2008
2113
  /** Custom function to generate human-readable names. Default: kebab-to-Title. */
2009
2114
  humanName?: (viewerName: string) => string;
2115
+ /** MCP Apps CSP — declares external domains the viewer needs (tiles, APIs, CDNs).
2116
+ * Uses McpUiCsp from @casys/mcp-compose (resourceDomains, connectDomains). */
2117
+ csp?: { resourceDomains?: string[]; connectDomains?: string[]; frameDomains?: string[] };
2010
2118
  }
2011
2119
 
2012
2120
  /** Summary returned by registerViewers() */
@@ -13,6 +13,8 @@ export interface CspOptions {
13
13
  readonly scriptSources?: readonly string[];
14
14
  /** Additional allowed connect sources (e.g. WebSocket endpoints). */
15
15
  readonly connectSources?: readonly string[];
16
+ /** Additional allowed image sources (e.g. tile servers). */
17
+ readonly imgSources?: readonly string[];
16
18
  /** Additional allowed frame ancestors. */
17
19
  readonly frameAncestors?: readonly string[];
18
20
  /**
@@ -49,7 +51,7 @@ export function buildCspHeader(options: CspOptions = {}): string {
49
51
  `default-src 'none'`,
50
52
  `script-src ${scriptSrc}`,
51
53
  `style-src 'self'${inlineDirective}`,
52
- `img-src 'self' data:`,
54
+ `img-src 'self' data: ${(options.imgSources ?? []).join(" ")}`.trim(),
53
55
  `font-src 'self'`,
54
56
  `connect-src ${connectSrc}`,
55
57
  `frame-ancestors ${frameAncestors}`,
package/src/types.ts CHANGED
@@ -78,12 +78,29 @@ export interface ConcurrentServerOptions {
78
78
  /** Enable sampling support for agentic tools (default: false) */
79
79
  enableSampling?: boolean;
80
80
 
81
+ /**
82
+ * Instructions for the LLM on how to use this server's tools.
83
+ * Sent in the MCP initialize response. The LLM sees this before any tool call.
84
+ */
85
+ instructions?: string;
86
+
81
87
  /** Sampling client implementation (required if enableSampling is true) */
82
88
  samplingClient?: SamplingClient;
83
89
 
84
90
  /** Custom logger function (default: console.error) */
85
91
  logger?: (msg: string) => void;
86
92
 
93
+ /**
94
+ * Custom error handler for tool execution errors.
95
+ *
96
+ * When set, errors thrown by tool handlers are passed to this function.
97
+ * Return a message string to produce `{ content: [{type:"text", text: msg}], isError: true }`
98
+ * instead of re-throwing. Return null to rethrow as a JSON-RPC error.
99
+ *
100
+ * Default: undefined (all errors are re-thrown, existing behaviour).
101
+ */
102
+ toolErrorMapper?: ToolErrorMapper;
103
+
87
104
  /**
88
105
  * OAuth2/Bearer authentication configuration.
89
106
  * When provided, HTTP requests require a valid Bearer token.
@@ -238,6 +255,23 @@ export const MCP_APP_URI_SCHEME = "ui:" as const;
238
255
  // MCP Tool Types
239
256
  // ============================================
240
257
 
258
+ /**
259
+ * Behavioural hints for model clients (MCP SDK 1.27 ToolAnnotations).
260
+ * Passed through in tools/list so hosts can adapt their UI accordingly.
261
+ */
262
+ export interface ToolAnnotations {
263
+ /** Short human-readable title, may differ from tool name */
264
+ title?: string;
265
+ /** If true, tool has no side-effects and is safe to call speculatively */
266
+ readOnlyHint?: boolean;
267
+ /** If true, executing may produce irreversible effects */
268
+ destructiveHint?: boolean;
269
+ /** If true, repeated calls with same args produce same result */
270
+ idempotentHint?: boolean;
271
+ /** If true, tool may interact with entities outside the MCP system */
272
+ openWorldHint?: boolean;
273
+ }
274
+
241
275
  /**
242
276
  * MCP Tool definition (compatible with MCP protocol)
243
277
  */
@@ -251,6 +285,15 @@ export interface MCPTool {
251
285
  /** JSON Schema for tool input */
252
286
  inputSchema: Record<string, unknown>;
253
287
 
288
+ /**
289
+ * JSON Schema for the tool's structured output (MCP SDK 1.27).
290
+ * Passed through in tools/list so hosts can validate tool results.
291
+ */
292
+ outputSchema?: Record<string, unknown>;
293
+
294
+ /** Behavioural hints passed to model clients */
295
+ annotations?: ToolAnnotations;
296
+
254
297
  /**
255
298
  * Tool metadata including UI configuration for MCP Apps
256
299
  * @see McpUiToolMeta
@@ -289,6 +332,32 @@ export type ToolHandler = (
289
332
  args: Record<string, unknown>,
290
333
  ) => Promise<unknown> | unknown;
291
334
 
335
+ /**
336
+ * Structured tool result: separates the LLM text summary (content)
337
+ * from the machine-readable payload (structuredContent).
338
+ *
339
+ * When a ToolHandler returns this shape, the framework produces a
340
+ * CallToolResult with both `content` and `structuredContent` set,
341
+ * keeping heavy data out of the LLM context.
342
+ */
343
+ export interface StructuredToolResult {
344
+ /** Human-readable summary shown in content[0].text */
345
+ content: string;
346
+ /** Structured data conforming to the tool's outputSchema */
347
+ structuredContent: Record<string, unknown>;
348
+ }
349
+
350
+ /**
351
+ * Maps a thrown error to either a business error result (isError: true)
352
+ * or signals that the error should be re-thrown as a JSON-RPC error.
353
+ *
354
+ * @returns A message string to produce `{ isError: true }`, or null to rethrow.
355
+ */
356
+ export type ToolErrorMapper = (
357
+ error: unknown,
358
+ toolName: string,
359
+ ) => string | null;
360
+
292
361
  /**
293
362
  * Sampling client interface for bidirectional LLM delegation
294
363
  * Compatible with the agentic sampling protocol (SEP-1577)