mcp-ts-template 1.5.1 → 1.5.3

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol SDK](https://img.shields.io/badge/MCP%20SDK-^1.12.3-green.svg)](https://github.com/modelcontextprotocol/typescript-sdk)
5
5
  [![MCP Spec Version](https://img.shields.io/badge/MCP%20Spec-2025--03--26-lightgrey.svg)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/changelog.mdx)
6
- [![Version](https://img.shields.io/badge/Version-1.5.1-blue.svg)](./CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/Version-1.5.3-blue.svg)](./CHANGELOG.md)
7
7
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
8
8
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/mcp-ts-template/issues)
9
9
  [![GitHub](https://img.shields.io/github/stars/cyanheads/mcp-ts-template?style=social)](https://github.com/cyanheads/mcp-ts-template)
@@ -15,8 +15,7 @@
15
15
  */
16
16
  import { McpError } from "../../types-global/errors.js";
17
17
  import { RequestContext } from "../../utils/index.js";
18
- import { // Import the new function
19
- type ConnectedMcpClient as CachedConnectedMcpClient } from "./clientCache.js";
18
+ import { type ConnectedMcpClient as CachedConnectedMcpClient } from "./clientCache.js";
20
19
  /**
21
20
  * Represents a successfully connected and initialized MCP Client instance.
22
21
  * This type alias is re-exported for external use.
@@ -25,16 +24,6 @@ export type ConnectedMcpClient = CachedConnectedMcpClient;
25
24
  /**
26
25
  * Creates, connects, or returns an existing/pending MCP client instance for a specified server.
27
26
  *
28
- * This function orchestrates the client connection lifecycle:
29
- * 1. **Cache Check**: Uses `clientCache.getCachedClient` to check for an active connection.
30
- * 2. **Pending Connection Check**: Uses `clientCache.getPendingConnection` to check for an in-flight connection attempt.
31
- * 3. **New Connection**: If no cached or pending connection exists:
32
- * a. A new connection promise is initiated by calling `establishNewMcpConnection`.
33
- * b. This promise is stored using `clientCache.setPendingConnection`.
34
- * c. Upon successful connection, the client is cached using `clientCache.setCachedClient`.
35
- * d. The pending promise is removed using `clientCache.removePendingConnection`.
36
- * 4. **Error Handling**: The entire process is wrapped in `ErrorHandler.tryCatch` for robust error management.
37
- *
38
27
  * @param serverName - The unique name of the MCP server to connect to.
39
28
  * @param parentContext - Optional parent `RequestContext` for logging and tracing.
40
29
  * @returns A promise that resolves to the connected and initialized `ConnectedMcpClient` instance.
@@ -43,7 +32,6 @@ export type ConnectedMcpClient = CachedConnectedMcpClient;
43
32
  export declare function connectMcpClient(serverName: string, parentContext?: RequestContext | null): Promise<ConnectedMcpClient>;
44
33
  /**
45
34
  * Disconnects a specific MCP client, closes its transport with a timeout, and removes it from the cache.
46
- * Idempotent: multiple calls for an already disconnected client will not cause errors.
47
35
  *
48
36
  * @param serverName - The name of the server whose client connection should be terminated.
49
37
  * @param parentContext - Optional parent `RequestContext` for logging.
@@ -53,7 +41,6 @@ export declare function connectMcpClient(serverName: string, parentContext?: Req
53
41
  export declare function disconnectMcpClient(serverName: string, parentContext?: RequestContext | null, error?: Error | McpError): Promise<void>;
54
42
  /**
55
43
  * Disconnects all currently active MCP client connections.
56
- * Typically used during application shutdown.
57
44
  *
58
45
  * @param parentContext - Optional parent `RequestContext` for logging.
59
46
  * @returns A promise that resolves when all disconnection attempts are processed.
@@ -15,23 +15,12 @@
15
15
  */
16
16
  import { BaseErrorCode } from "../../types-global/errors.js";
17
17
  import { ErrorHandler, logger, requestContextService, } from "../../utils/index.js";
18
- import { getCachedClient, getPendingConnection, removeClientFromCache, removePendingConnection, setCachedClient, setPendingConnection, clearAllClientCache, getAllCachedServerNames, // Import the new function
19
- } from "./clientCache.js";
18
+ import { clearAllClientCache, getAllCachedServerNames, getCachedClient, getPendingConnection, removeClientFromCache, removePendingConnection, setCachedClient, setPendingConnection, } from "./clientCache.js";
20
19
  import { establishNewMcpConnection } from "./clientConnectionLogic.js";
21
20
  const SHUTDOWN_TIMEOUT_MS = 5000; // 5 seconds for client.close() timeout
22
21
  /**
23
22
  * Creates, connects, or returns an existing/pending MCP client instance for a specified server.
24
23
  *
25
- * This function orchestrates the client connection lifecycle:
26
- * 1. **Cache Check**: Uses `clientCache.getCachedClient` to check for an active connection.
27
- * 2. **Pending Connection Check**: Uses `clientCache.getPendingConnection` to check for an in-flight connection attempt.
28
- * 3. **New Connection**: If no cached or pending connection exists:
29
- * a. A new connection promise is initiated by calling `establishNewMcpConnection`.
30
- * b. This promise is stored using `clientCache.setPendingConnection`.
31
- * c. Upon successful connection, the client is cached using `clientCache.setCachedClient`.
32
- * d. The pending promise is removed using `clientCache.removePendingConnection`.
33
- * 4. **Error Handling**: The entire process is wrapped in `ErrorHandler.tryCatch` for robust error management.
34
- *
35
24
  * @param serverName - The unique name of the MCP server to connect to.
36
25
  * @param parentContext - Optional parent `RequestContext` for logging and tracing.
37
26
  * @returns A promise that resolves to the connected and initialized `ConnectedMcpClient` instance.
@@ -55,7 +44,6 @@ export async function connectMcpClient(serverName, parentContext) {
55
44
  }
56
45
  logger.info(`No active or pending connection for ${serverName}. Initiating new connection.`, operationContext);
57
46
  const connectionPromise = ErrorHandler.tryCatch(async () => {
58
- // Pass the local disconnectMcpClient function to break the circular dependency
59
47
  const client = await establishNewMcpConnection(serverName, operationContext, disconnectMcpClient);
60
48
  setCachedClient(serverName, client);
61
49
  return client;
@@ -71,7 +59,6 @@ export async function connectMcpClient(serverName, parentContext) {
71
59
  }
72
60
  /**
73
61
  * Disconnects a specific MCP client, closes its transport with a timeout, and removes it from the cache.
74
- * Idempotent: multiple calls for an already disconnected client will not cause errors.
75
62
  *
76
63
  * @param serverName - The name of the server whose client connection should be terminated.
77
64
  * @param parentContext - Optional parent `RequestContext` for logging.
@@ -85,37 +72,32 @@ export async function disconnectMcpClient(serverName, parentContext, error) {
85
72
  targetServer: serverName,
86
73
  triggerReason: error
87
74
  ? `Error: ${error.message}`
88
- : "Explicit disconnect call or transport close event",
75
+ : "Explicit disconnect call",
89
76
  });
90
77
  const client = getCachedClient(serverName);
91
78
  if (!client) {
92
79
  if (!error) {
93
- logger.warning(`Client for server ${serverName} not found in cache or already disconnected. No action taken.`, context);
80
+ logger.warning(`Client for ${serverName} not found in cache or already disconnected.`, context);
94
81
  }
95
82
  removeClientFromCache(serverName, "Not found during disconnect");
96
83
  return;
97
84
  }
98
85
  logger.info(`Disconnecting client for server: ${serverName}...`, context);
99
- try {
86
+ await ErrorHandler.tryCatch(async () => {
100
87
  const closePromise = client.close();
101
88
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout: client.close() for ${serverName} exceeded ${SHUTDOWN_TIMEOUT_MS}ms`)), SHUTDOWN_TIMEOUT_MS));
102
89
  await Promise.race([closePromise, timeoutPromise]);
103
90
  logger.info(`Client for ${serverName} and its transport closed successfully.`, context);
104
- }
105
- catch (closeError) {
106
- logger.error(`Error during client.close() for server ${serverName} (or timeout)`, {
107
- ...context,
108
- error: closeError instanceof Error ? closeError.message : String(closeError),
109
- stack: closeError instanceof Error ? closeError.stack : undefined,
110
- });
111
- }
112
- finally {
91
+ }, {
92
+ operation: `disconnectMcpClient.close (server: ${serverName})`,
93
+ context,
94
+ errorCode: BaseErrorCode.SHUTDOWN_ERROR,
95
+ }).finally(() => {
113
96
  removeClientFromCache(serverName, "After close attempt");
114
- }
97
+ });
115
98
  }
116
99
  /**
117
100
  * Disconnects all currently active MCP client connections.
118
- * Typically used during application shutdown.
119
101
  *
120
102
  * @param parentContext - Optional parent `RequestContext` for logging.
121
103
  * @returns A promise that resolves when all disconnection attempts are processed.
@@ -129,32 +111,13 @@ export async function disconnectAllMcpClients(parentContext) {
129
111
  const serverNamesToDisconnect = getAllCachedServerNames();
130
112
  if (serverNamesToDisconnect.length === 0) {
131
113
  logger.info("No active MCP clients to disconnect.", context);
132
- // Still call clearAllClientCache in case pending connections exist or for consistency
133
114
  clearAllClientCache();
134
- logger.info("Finished processing all client disconnections (no active clients found, cache cleared).", context);
135
115
  return;
136
116
  }
137
117
  logger.debug(`Found ${serverNamesToDisconnect.length} active clients to disconnect: ${serverNamesToDisconnect.join(", ")}`, context);
138
118
  const disconnectionPromises = serverNamesToDisconnect.map((serverName) => disconnectMcpClient(serverName, context));
139
- const results = await Promise.allSettled(disconnectionPromises);
140
- logger.info("All MCP client disconnection attempts completed. Reviewing results...", context);
141
- results.forEach((result, index) => {
142
- const serverName = serverNamesToDisconnect[index];
143
- if (result.status === "rejected") {
144
- logger.error(`Failed to cleanly disconnect client for server: ${serverName}`, {
145
- ...context,
146
- targetServer: serverName,
147
- error: result.reason instanceof Error
148
- ? result.reason.message
149
- : String(result.reason),
150
- stack: result.reason instanceof Error ? result.reason.stack : undefined,
151
- });
152
- }
153
- else {
154
- logger.debug(`Successfully processed disconnection for server: ${serverName}.`, { ...context, targetServer: serverName });
155
- }
156
- });
157
- // Ensure all caches are cleared regardless of individual disconnections.
119
+ await Promise.allSettled(disconnectionPromises);
120
+ logger.info("All MCP client disconnection attempts have been processed.", context);
158
121
  clearAllClientCache();
159
- logger.info("Finished processing all client disconnections and cleared caches.", context);
122
+ logger.info("All client caches have been cleared.", context);
160
123
  }
@@ -16,66 +16,48 @@ import { createHttpClientTransport } from "./httpClientTransport.js";
16
16
  * transport type is specified, or if transport creation fails.
17
17
  */
18
18
  export function getClientTransport(serverName, parentContext) {
19
- const baseContext = parentContext ? { ...parentContext } : {};
20
19
  const context = requestContextService.createRequestContext({
21
- ...baseContext,
20
+ ...(parentContext ?? {}),
22
21
  operation: "getClientTransport",
23
22
  targetServer: serverName,
24
23
  });
25
24
  logger.info(`Getting transport for server: ${serverName}`, context);
26
25
  try {
27
26
  const serverConfig = getMcpServerConfig(serverName, context);
28
- const transportType = serverConfig.transportType;
27
+ const { transportType, command, args, env } = serverConfig;
29
28
  logger.info(`Selected transport type "${transportType}" for server: ${serverName}`, { ...context, transportType });
30
- if (transportType === "stdio") {
31
- logger.info(`Creating stdio transport for server: ${serverName}`, {
32
- ...context,
33
- command: serverConfig.command,
34
- args: serverConfig.args,
35
- envProvided: !!serverConfig.env,
36
- });
37
- return createStdioClientTransport({
38
- command: serverConfig.command,
39
- args: serverConfig.args,
40
- env: serverConfig.env,
41
- }, context);
42
- }
43
- else if (transportType === "http") {
44
- const baseUrl = serverConfig.command; // In HTTP config, 'command' holds the baseUrl
45
- // Validate baseUrl for HTTP transport
46
- if (!baseUrl ||
47
- typeof baseUrl !== "string" ||
48
- !baseUrl.startsWith("http")) {
49
- const httpConfigError = `Invalid configuration for HTTP transport server "${serverName}": The 'command' field (used as baseUrl for HTTP) must be a valid URL string starting with http(s). Found: "${baseUrl}"`;
50
- logger.error(httpConfigError, context);
51
- throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, httpConfigError, context);
29
+ switch (transportType) {
30
+ case "stdio":
31
+ logger.info(`Creating stdio transport for server: ${serverName}`, {
32
+ ...context,
33
+ command,
34
+ args,
35
+ envProvided: !!env,
36
+ });
37
+ return createStdioClientTransport({ command, args, env }, context);
38
+ case "http": {
39
+ const baseUrl = command;
40
+ if (!baseUrl ||
41
+ typeof baseUrl !== "string" ||
42
+ !baseUrl.startsWith("http")) {
43
+ throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Invalid 'command' for HTTP transport (must be a valid URL): "${baseUrl}"`, context);
44
+ }
45
+ logger.info(`Creating HTTP transport for server: ${serverName} with base URL: ${baseUrl}`, context);
46
+ return createHttpClientTransport({ baseUrl }, context);
52
47
  }
53
- logger.info(`Creating HTTP transport for server: ${serverName} with base URL: ${baseUrl}`, context);
54
- return createHttpClientTransport({
55
- baseUrl: baseUrl,
56
- }, context);
57
- }
58
- else {
59
- const unsupportedErrorMessage = `Unsupported transportType "${serverConfig.transportType}" configured for server "${serverName}".`;
60
- logger.error(unsupportedErrorMessage, context);
61
- throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, unsupportedErrorMessage, context);
48
+ default:
49
+ throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, `Unsupported transportType "${transportType}" for server "${serverName}".`, context);
62
50
  }
63
51
  }
64
52
  catch (error) {
65
- const errorMessage = error instanceof Error ? error.message : String(error);
66
53
  logger.error(`Failed to get or create transport for server "${serverName}"`, {
67
54
  ...context,
68
- error: errorMessage,
55
+ error: error instanceof Error ? error.message : String(error),
69
56
  stack: error instanceof Error ? error.stack : undefined,
70
57
  });
71
58
  if (error instanceof McpError) {
72
- throw error; // Re-throw McpError instances directly as they should have context.
73
- }
74
- else {
75
- // For unexpected errors not already McpError, wrap them.
76
- // These are likely programming errors or unexpected system issues.
77
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, // Use INTERNAL_ERROR for unexpected issues
78
- `Unexpected error while getting transport for ${serverName}: ${errorMessage}`, { originalError: error, ...context });
59
+ throw error;
79
60
  }
61
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Unexpected error while getting transport for ${serverName}: ${error instanceof Error ? error.message : String(error)}`, { originalError: error, ...context });
80
62
  }
81
63
  }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * @fileoverview Core logic for the fetch_image_test tool. Fetches a random cat image.
3
+ * @module src/mcp-server/tools/imageTest/logic
4
+ */
1
5
  import { z } from "zod";
2
6
  import { RequestContext } from "../../../utils/index.js";
3
7
  export declare const FetchImageTestInputSchema: z.ZodObject<{
@@ -2,10 +2,9 @@
2
2
  * @fileoverview Core logic for the fetch_image_test tool. Fetches a random cat image.
3
3
  * @module src/mcp-server/tools/imageTest/logic
4
4
  */
5
- import fetch from "node-fetch";
6
5
  import { z } from "zod";
7
6
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
8
- import { logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js";
7
+ import { fetchWithTimeout, logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js";
9
8
  export const FetchImageTestInputSchema = z.object({
10
9
  trigger: z
11
10
  .boolean()
@@ -21,7 +20,7 @@ export async function fetchImageTestLogic(input, parentRequestContext) {
21
20
  input: sanitizeInputForLogging(input),
22
21
  });
23
22
  logger.info(`Executing 'fetch_image_test'. Trigger: ${input.trigger}`, operationContext);
24
- const response = await fetch(CAT_API_URL);
23
+ const response = await fetchWithTimeout(CAT_API_URL, 5000, operationContext);
25
24
  if (!response.ok) {
26
25
  throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Failed to fetch cat image from ${CAT_API_URL}. Status: ${response.status}`, {
27
26
  ...operationContext,
@@ -12,17 +12,6 @@ export interface OpenRouterClientOptions {
12
12
  }
13
13
  /**
14
14
  * Defines the parameters for an OpenRouter chat completion request.
15
- * This type extends standard OpenAI chat completion parameters and includes
16
- * OpenRouter-specific fields.
17
- *
18
- * @property top_k - OpenRouter specific: Sample from the k most likely next tokens.
19
- * @property min_p - OpenRouter specific: Minimum probability for a token to be considered.
20
- * @property transforms - OpenRouter specific: Apply transformations to the request or response.
21
- * @property models - OpenRouter specific: A list of models to use, often for fallback or routing.
22
- * @property route - OpenRouter specific: Specifies routing strategy, e.g., 'fallback'.
23
- * @property provider - OpenRouter specific: Provider-specific parameters or routing preferences.
24
- * @property stream - If true, the response will be a stream of `ChatCompletionChunk` objects.
25
- * If false or undefined, a single `ChatCompletion` object is returned.
26
15
  */
27
16
  export type OpenRouterChatParams = (ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming) & {
28
17
  top_k?: number;
@@ -34,74 +23,18 @@ export type OpenRouterChatParams = (ChatCompletionCreateParamsNonStreaming | Cha
34
23
  };
35
24
  /**
36
25
  * Service class for interacting with the OpenRouter API.
37
- * Uses the OpenAI SDK for chat completions, configured for OpenRouter.
38
- * Handles API key management, default headers, model-specific parameter adjustments,
39
- * and provides methods for chat completions and listing models.
26
+ * Acts as a "handler" that manages state, configuration, and error handling,
27
+ * wrapping calls to the internal core logic functions.
40
28
  */
41
29
  declare class OpenRouterProvider {
42
- /**
43
- * The OpenAI SDK client instance configured for OpenRouter.
44
- * @private
45
- */
46
30
  private client?;
47
- /**
48
- * Current status of the OpenRouter service.
49
- * - `unconfigured`: API key is missing.
50
- * - `initializing`: Constructor is running.
51
- * - `ready`: Client initialized successfully and service is usable.
52
- * - `error`: An error occurred during initialization.
53
- */
54
31
  status: "unconfigured" | "initializing" | "ready" | "error";
55
- /**
56
- * Stores any error that occurred during client initialization.
57
- * @private
58
- */
59
32
  private initializationError;
60
- /**
61
- * Constructs an `OpenRouterProvider` instance.
62
- * Initializes the OpenAI client for OpenRouter if an API key is provided.
63
- * Sets default headers required by OpenRouter.
64
- * @param options - Optional configuration for the OpenRouter client.
65
- * @param parentOpContext - Optional parent operation context for linked logging.
66
- */
67
33
  constructor(options?: OpenRouterClientOptions, parentOpContext?: OperationContext);
68
- /**
69
- * Checks if the service is ready to make API calls.
70
- * @param operation - The name of the operation attempting to use the service.
71
- * @param context - The request context for logging.
72
- * @throws {McpError} If the service is not ready.
73
- * @private
74
- */
75
34
  private checkReady;
76
- /**
77
- * Creates a chat completion using the OpenRouter API.
78
- * Can return either a single response or a stream of chunks.
79
- * Applies rate limiting and handles model-specific parameter adjustments.
80
- *
81
- * @param params - Parameters for the chat completion request.
82
- * @param context - Request context for logging, error handling, and rate limiting.
83
- * @returns A promise resolving with either a `ChatCompletion` or a `Stream<ChatCompletionChunk>`.
84
- * @throws {McpError} If service not ready, rate limit exceeded, or API call fails.
85
- */
86
35
  chatCompletion(params: OpenRouterChatParams, context: RequestContext): Promise<ChatCompletion | Stream<ChatCompletionChunk>>;
87
- /**
88
- * Lists available models from the OpenRouter API.
89
- * Makes a direct `fetch` call to the `/models` endpoint.
90
- *
91
- * @param context - Request context for logging and error handling.
92
- * @returns A promise resolving with the JSON response from the OpenRouter API.
93
- * @throws {McpError} If the service is not ready, or if the API call fails.
94
- */
95
36
  listModels(context: RequestContext): Promise<any>;
96
37
  }
97
- /**
98
- * Singleton instance of the `OpenRouterProvider`.
99
- * Initialized with the OpenRouter API key from application configuration.
100
- */
101
38
  declare const openRouterProviderInstance: OpenRouterProvider;
102
39
  export { openRouterProviderInstance as openRouterProvider };
103
- /**
104
- * Exporting the type of the OpenRouterProvider class for use in dependency injection
105
- * or for type hinting elsewhere in the application.
106
- */
107
40
  export type { OpenRouterProvider };
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @fileoverview Provides a service class (`OpenRouterProvider`) for interacting with the
3
- * OpenRouter API, using the OpenAI SDK for chat completions. It handles API key
4
- * configuration, default parameters, rate limiting, model-specific parameter adjustments,
5
- * and error handling.
3
+ * OpenRouter API. This file implements the "handler" pattern internally, where the
4
+ * OpenRouterProvider class manages state and error handling, while private logic functions
5
+ * execute the core API interactions and throw structured errors.
6
6
  * @module src/services/llm-providers/openRouterProvider
7
7
  */
8
8
  import OpenAI from "openai";
@@ -13,316 +13,225 @@ import { logger } from "../../utils/internal/logger.js";
13
13
  import { requestContextService, } from "../../utils/internal/requestContext.js";
14
14
  import { rateLimiter } from "../../utils/security/rateLimiter.js";
15
15
  import { sanitization } from "../../utils/security/sanitization.js";
16
+ // #region Internal Logic Functions (Throwing Errors)
17
+ /**
18
+ * Prepares parameters for the OpenRouter API call, separating standard
19
+ * and extra parameters and applying defaults.
20
+ * @internal
21
+ */
22
+ function _prepareApiParameters(params, context) {
23
+ const operation = "openRouterLogic.prepareApiParameters";
24
+ const effectiveModelId = params.model || config.llmDefaultModel;
25
+ const standardParams = {
26
+ model: effectiveModelId,
27
+ messages: params.messages,
28
+ ...(params.temperature !== undefined ||
29
+ config.llmDefaultTemperature !== undefined
30
+ ? { temperature: params.temperature ?? config.llmDefaultTemperature }
31
+ : {}),
32
+ ...(params.top_p !== undefined || config.llmDefaultTopP !== undefined
33
+ ? { top_p: params.top_p ?? config.llmDefaultTopP }
34
+ : {}),
35
+ ...(params.presence_penalty !== undefined
36
+ ? { presence_penalty: params.presence_penalty }
37
+ : {}),
38
+ ...(params.stream !== undefined && { stream: params.stream }),
39
+ ...(params.tools !== undefined && { tools: params.tools }),
40
+ ...(params.tool_choice !== undefined && {
41
+ tool_choice: params.tool_choice,
42
+ }),
43
+ ...(params.response_format !== undefined && {
44
+ response_format: params.response_format,
45
+ }),
46
+ ...(params.stop !== undefined && { stop: params.stop }),
47
+ ...(params.seed !== undefined && { seed: params.seed }),
48
+ ...(params.frequency_penalty !== undefined
49
+ ? { frequency_penalty: params.frequency_penalty }
50
+ : {}),
51
+ ...(params.logit_bias !== undefined && { logit_bias: params.logit_bias }),
52
+ };
53
+ const extraBody = {};
54
+ const standardKeys = new Set(Object.keys(standardParams));
55
+ standardKeys.add("messages");
56
+ for (const key in params) {
57
+ if (Object.prototype.hasOwnProperty.call(params, key) &&
58
+ !standardKeys.has(key) &&
59
+ key !== "max_tokens") {
60
+ extraBody[key] = params[key];
61
+ }
62
+ }
63
+ if (extraBody.top_k === undefined && config.llmDefaultTopK !== undefined) {
64
+ extraBody.top_k = config.llmDefaultTopK;
65
+ }
66
+ if (extraBody.min_p === undefined && config.llmDefaultMinP !== undefined) {
67
+ extraBody.min_p = config.llmDefaultMinP;
68
+ }
69
+ if (extraBody.provider && typeof extraBody.provider === "object") {
70
+ if (!extraBody.provider.sort)
71
+ extraBody.provider.sort = "throughput";
72
+ }
73
+ else if (extraBody.provider === undefined) {
74
+ extraBody.provider = { sort: "throughput" };
75
+ }
76
+ const modelsRequiringMaxCompletionTokens = ["openai/o1", "openai/gpt-4.1"];
77
+ const needsMaxCompletionTokens = modelsRequiringMaxCompletionTokens.some((modelPrefix) => effectiveModelId.startsWith(modelPrefix));
78
+ const effectiveMaxTokensValue = params.max_tokens ?? config.llmDefaultMaxTokens;
79
+ if (effectiveMaxTokensValue !== undefined) {
80
+ if (needsMaxCompletionTokens) {
81
+ extraBody.max_completion_tokens = effectiveMaxTokensValue;
82
+ logger.info(`[${operation}] Using 'max_completion_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId}.`, context);
83
+ }
84
+ else {
85
+ standardParams.max_tokens = effectiveMaxTokensValue;
86
+ logger.info(`[${operation}] Using 'max_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId}.`, context);
87
+ }
88
+ }
89
+ return { standardParams, extraBody };
90
+ }
91
+ /**
92
+ * Core logic for making a chat completion request. Throws McpError on failure.
93
+ * @internal
94
+ */
95
+ async function _openRouterChatCompletionLogic(client, params, context) {
96
+ const operation = "openRouterLogic.chatCompletion";
97
+ const isStreaming = params.stream === true;
98
+ const { standardParams, extraBody } = _prepareApiParameters(params, context);
99
+ const apiParams = { ...standardParams };
100
+ if (Object.keys(extraBody).length > 0) {
101
+ apiParams.extra_body = extraBody;
102
+ }
103
+ try {
104
+ if (isStreaming) {
105
+ return await client.chat.completions.create(apiParams);
106
+ }
107
+ return await client.chat.completions.create(apiParams);
108
+ }
109
+ catch (error) {
110
+ logger.error(`[${operation}] API call failed`, {
111
+ ...context,
112
+ error: error.message,
113
+ status: error.status,
114
+ });
115
+ const errorDetails = {
116
+ providerStatus: error.status,
117
+ providerMessage: error.message,
118
+ cause: error?.cause,
119
+ };
120
+ if (error.status === 401) {
121
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, `OpenRouter authentication failed: ${error.message}`, errorDetails);
122
+ }
123
+ else if (error.status === 429) {
124
+ throw new McpError(BaseErrorCode.RATE_LIMITED, `OpenRouter rate limit exceeded: ${error.message}`, errorDetails);
125
+ }
126
+ else if (error.status === 402) {
127
+ throw new McpError(BaseErrorCode.FORBIDDEN, `OpenRouter insufficient credits or payment required: ${error.message}`, errorDetails);
128
+ }
129
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter API error (${error.status || "unknown status"}): ${error.message}`, errorDetails);
130
+ }
131
+ }
132
+ /**
133
+ * Core logic for fetching the list of available models. Throws McpError on failure.
134
+ * @internal
135
+ */
136
+ async function _listModelsLogic(context) {
137
+ const operation = "openRouterLogic.listModels";
138
+ try {
139
+ const response = await fetch("https://openrouter.ai/api/v1/models", {
140
+ method: "GET",
141
+ headers: { "Content-Type": "application/json" },
142
+ });
143
+ if (!response.ok) {
144
+ const errorBody = await response.text();
145
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter list models API request failed with status ${response.status}.`, { providerStatus: response.status, providerMessage: errorBody });
146
+ }
147
+ return await response.json();
148
+ }
149
+ catch (error) {
150
+ logger.error(`[${operation}] Error listing models`, {
151
+ ...context,
152
+ error: error.message,
153
+ });
154
+ if (error instanceof McpError)
155
+ throw error;
156
+ throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Network or unexpected error listing OpenRouter models: ${error.message}`, { cause: error });
157
+ }
158
+ }
159
+ // #endregion
16
160
  /**
17
161
  * Service class for interacting with the OpenRouter API.
18
- * Uses the OpenAI SDK for chat completions, configured for OpenRouter.
19
- * Handles API key management, default headers, model-specific parameter adjustments,
20
- * and provides methods for chat completions and listing models.
162
+ * Acts as a "handler" that manages state, configuration, and error handling,
163
+ * wrapping calls to the internal core logic functions.
21
164
  */
22
165
  class OpenRouterProvider {
23
- /**
24
- * Constructs an `OpenRouterProvider` instance.
25
- * Initializes the OpenAI client for OpenRouter if an API key is provided.
26
- * Sets default headers required by OpenRouter.
27
- * @param options - Optional configuration for the OpenRouter client.
28
- * @param parentOpContext - Optional parent operation context for linked logging.
29
- */
30
166
  constructor(options, parentOpContext) {
31
- /**
32
- * Stores any error that occurred during client initialization.
33
- * @private
34
- */
35
167
  this.initializationError = null;
36
- const operationName = parentOpContext?.operation
37
- ? `${parentOpContext.operation}.OpenRouterProvider.constructor`
38
- : "OpenRouterProvider.constructor";
39
168
  const opContext = requestContextService.createRequestContext({
40
- operation: operationName,
169
+ operation: "OpenRouterProvider.constructor",
41
170
  parentRequestId: parentOpContext?.requestId,
42
171
  });
43
172
  this.status = "initializing";
44
173
  const apiKey = options?.apiKey || config.openrouterApiKey;
45
- const baseURL = options?.baseURL || "https://openrouter.ai/api/v1";
46
- const siteUrl = options?.siteUrl || config.openrouterAppUrl;
47
- const siteName = options?.siteName || config.openrouterAppName;
48
174
  if (!apiKey) {
49
175
  this.status = "unconfigured";
50
- this.initializationError = new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OpenRouter API key is not configured.", { operation: operationName });
51
- logger.error(`[${operationName}] OpenRouter API key not provided in options or global config. Service is unconfigured.`, { ...opContext, service: "OpenRouterProvider" });
176
+ this.initializationError = new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OpenRouter API key is not configured.");
177
+ logger.error(this.initializationError.message, opContext);
52
178
  return;
53
179
  }
54
180
  try {
55
181
  this.client = new OpenAI({
56
- baseURL,
182
+ baseURL: options?.baseURL || "https://openrouter.ai/api/v1",
57
183
  apiKey,
58
184
  defaultHeaders: {
59
- "HTTP-Referer": siteUrl,
60
- "X-Title": siteName,
185
+ "HTTP-Referer": options?.siteUrl || config.openrouterAppUrl,
186
+ "X-Title": options?.siteName || config.openrouterAppName,
61
187
  },
62
188
  });
63
189
  this.status = "ready";
64
- logger.info("OpenRouter Service Initialized and Ready", {
65
- ...opContext,
66
- service: "OpenRouterProvider",
67
- });
190
+ logger.info("OpenRouter Service Initialized and Ready", opContext);
68
191
  }
69
192
  catch (error) {
70
193
  this.status = "error";
71
- this.initializationError =
72
- error instanceof Error
73
- ? error
74
- : new McpError(BaseErrorCode.INITIALIZATION_FAILED, String(error));
194
+ this.initializationError = error;
75
195
  logger.error("Failed to initialize OpenRouter client", {
76
196
  ...opContext,
77
- service: "OpenRouterProvider",
78
- error: this.initializationError.message,
197
+ error: error.message,
79
198
  });
80
199
  }
81
200
  }
82
- /**
83
- * Checks if the service is ready to make API calls.
84
- * @param operation - The name of the operation attempting to use the service.
85
- * @param context - The request context for logging.
86
- * @throws {McpError} If the service is not ready.
87
- * @private
88
- */
89
201
  checkReady(operation, context) {
90
- if (this.status !== "ready") {
91
- let errorCode = BaseErrorCode.SERVICE_UNAVAILABLE;
92
- let message = `OpenRouter service is not available (status: ${this.status}).`;
93
- if (this.status === "unconfigured") {
94
- errorCode = BaseErrorCode.CONFIGURATION_ERROR;
95
- message = "OpenRouter service is not configured (missing API key).";
96
- }
97
- else if (this.status === "error") {
98
- errorCode = BaseErrorCode.INITIALIZATION_FAILED;
99
- message = `OpenRouter service failed to initialize: ${this.initializationError?.message || "Unknown error"}`;
100
- }
101
- logger.error(`[${operation}] Attempted to use OpenRouter service when not ready.`, { ...context, status: this.status });
102
- throw new McpError(errorCode, message, {
103
- operation,
104
- status: this.status,
202
+ if (this.status !== "ready" || !this.client) {
203
+ const message = `OpenRouter service is not available (status: ${this.status}).`;
204
+ logger.error(`[${operation}] ${message}`, { ...context, status: this.status });
205
+ throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, message, {
105
206
  cause: this.initializationError,
106
207
  });
107
208
  }
108
- if (!this.client) {
109
- // This should ideally not happen if status is 'ready', but as a safeguard:
110
- logger.error(`[${operation}] Service status is ready, but client is missing.`, { ...context });
111
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Internal inconsistency: OpenRouter client is missing despite ready status.", { operation });
112
- }
113
209
  }
114
- /**
115
- * Creates a chat completion using the OpenRouter API.
116
- * Can return either a single response or a stream of chunks.
117
- * Applies rate limiting and handles model-specific parameter adjustments.
118
- *
119
- * @param params - Parameters for the chat completion request.
120
- * @param context - Request context for logging, error handling, and rate limiting.
121
- * @returns A promise resolving with either a `ChatCompletion` or a `Stream<ChatCompletionChunk>`.
122
- * @throws {McpError} If service not ready, rate limit exceeded, or API call fails.
123
- */
124
210
  async chatCompletion(params, context) {
125
211
  const operation = "OpenRouterProvider.chatCompletion";
126
- this.checkReady(operation, context);
127
- const isStreaming = params.stream === true;
128
- const effectiveModelId = params.model || config.llmDefaultModel;
129
- const standardParams = {
130
- model: effectiveModelId,
131
- messages: params.messages,
132
- ...(params.temperature !== undefined ||
133
- config.llmDefaultTemperature !== undefined
134
- ? { temperature: params.temperature ?? config.llmDefaultTemperature }
135
- : {}),
136
- ...(params.top_p !== undefined || config.llmDefaultTopP !== undefined
137
- ? { top_p: params.top_p ?? config.llmDefaultTopP }
138
- : {}),
139
- ...(params.presence_penalty !== undefined
140
- ? { presence_penalty: params.presence_penalty }
141
- : {}),
142
- ...(params.stream !== undefined && { stream: params.stream }),
143
- ...(params.tools !== undefined && { tools: params.tools }),
144
- ...(params.tool_choice !== undefined && {
145
- tool_choice: params.tool_choice,
146
- }),
147
- ...(params.response_format !== undefined && {
148
- response_format: params.response_format,
149
- }),
150
- ...(params.stop !== undefined && { stop: params.stop }),
151
- ...(params.seed !== undefined && { seed: params.seed }),
152
- ...(params.frequency_penalty !== undefined
153
- ? { frequency_penalty: params.frequency_penalty }
154
- : {}),
155
- ...(params.logit_bias !== undefined && { logit_bias: params.logit_bias }),
156
- };
157
- const extraBody = {};
158
- const standardKeys = new Set(Object.keys(standardParams));
159
- standardKeys.add("messages");
160
- for (const key in params) {
161
- if (Object.prototype.hasOwnProperty.call(params, key) &&
162
- !standardKeys.has(key) &&
163
- key !== "max_tokens") {
164
- extraBody[key] = params[key];
165
- }
166
- }
167
- if (extraBody.top_k === undefined && config.llmDefaultTopK !== undefined) {
168
- extraBody.top_k = config.llmDefaultTopK;
169
- }
170
- if (extraBody.min_p === undefined && config.llmDefaultMinP !== undefined) {
171
- extraBody.min_p = config.llmDefaultMinP;
172
- }
173
- if (extraBody.provider && typeof extraBody.provider === "object") {
174
- if (!extraBody.provider.sort)
175
- extraBody.provider.sort = "throughput";
176
- }
177
- else if (extraBody.provider === undefined) {
178
- extraBody.provider = { sort: "throughput" };
179
- }
180
- const modelsRequiringMaxCompletionTokens = ["openai/o1", "openai/gpt-4.1"];
181
- const needsMaxCompletionTokens = modelsRequiringMaxCompletionTokens.some((modelPrefix) => effectiveModelId.startsWith(modelPrefix));
182
- const effectiveMaxTokensValue = params.max_tokens ?? config.llmDefaultMaxTokens;
183
- if (effectiveMaxTokensValue !== undefined) {
184
- if (needsMaxCompletionTokens) {
185
- extraBody.max_completion_tokens = effectiveMaxTokensValue;
186
- logger.info(`[${operation}] Using 'max_completion_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId} (sent via extra_body).`, context);
187
- }
188
- else {
189
- standardParams.max_tokens = effectiveMaxTokensValue;
190
- logger.info(`[${operation}] Using 'max_tokens: ${effectiveMaxTokensValue}' for model ${effectiveModelId}.`, context);
191
- }
192
- }
193
- const allEffectiveParams = { ...standardParams, ...extraBody };
194
- const sanitizedParams = sanitization.sanitizeForLogging(allEffectiveParams);
195
- logger.info(`[${operation}] Request received`, {
196
- ...context,
197
- params: sanitizedParams,
198
- streaming: isStreaming,
199
- });
200
- const rateLimitKey = context.requestId || "openrouter_default_key";
201
- try {
212
+ const sanitizedParams = sanitization.sanitizeForLogging(params);
213
+ return await ErrorHandler.tryCatch(async () => {
214
+ this.checkReady(operation, context);
215
+ const rateLimitKey = context.requestId || "openrouter_default_key";
202
216
  rateLimiter.check(rateLimitKey, context);
203
- logger.debug(`[${operation}] Rate limit check passed`, {
204
- ...context,
205
- key: rateLimitKey,
206
- });
207
- }
208
- catch (error) {
209
- logger.warning(`[${operation}] Rate limit exceeded`, {
217
+ const result = await _openRouterChatCompletionLogic(this.client, params, context);
218
+ logger.info(`[${operation}] Request successful`, {
210
219
  ...context,
211
- key: rateLimitKey,
212
- error: error instanceof Error ? error.message : String(error),
220
+ model: params.model,
221
+ streaming: params.stream,
213
222
  });
214
- throw error;
215
- }
216
- return await ErrorHandler.tryCatch(async () => {
217
- if (!this.client)
218
- throw new Error("Client missing despite ready status");
219
- const apiParams = { ...standardParams };
220
- if (Object.keys(extraBody).length > 0) {
221
- apiParams.extra_body = extraBody;
222
- }
223
- try {
224
- if (isStreaming) {
225
- const stream = await this.client.chat.completions.create(apiParams);
226
- logger.info(`[${operation}] Streaming request successful`, {
227
- ...context,
228
- model: apiParams.model,
229
- });
230
- return stream;
231
- }
232
- else {
233
- const completion = await this.client.chat.completions.create(apiParams);
234
- logger.info(`[${operation}] Non-streaming request successful`, {
235
- ...context,
236
- model: apiParams.model,
237
- });
238
- return completion;
239
- }
240
- }
241
- catch (error) {
242
- logger.error(`[${operation}] API call failed`, {
243
- ...context,
244
- error: error.message,
245
- status: error.status,
246
- });
247
- const errorDetails = {
248
- providerStatus: error.status,
249
- providerMessage: error.message,
250
- cause: error?.cause,
251
- };
252
- if (error.status === 401) {
253
- throw new McpError(BaseErrorCode.UNAUTHORIZED, `OpenRouter authentication failed: ${error.message}`, errorDetails);
254
- }
255
- else if (error.status === 429) {
256
- throw new McpError(BaseErrorCode.RATE_LIMITED, `OpenRouter rate limit exceeded: ${error.message}`, errorDetails);
257
- }
258
- else if (error.status === 402) {
259
- throw new McpError(BaseErrorCode.FORBIDDEN, `OpenRouter insufficient credits or payment required: ${error.message}`, errorDetails);
260
- }
261
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter API error (${error.status || "unknown status"}): ${error.message}`, errorDetails);
262
- }
263
- }, {
264
- operation,
265
- context,
266
- input: sanitizedParams,
267
- errorCode: BaseErrorCode.INTERNAL_ERROR,
268
- });
223
+ return result;
224
+ }, { operation, context, input: sanitizedParams });
269
225
  }
270
- /**
271
- * Lists available models from the OpenRouter API.
272
- * Makes a direct `fetch` call to the `/models` endpoint.
273
- *
274
- * @param context - Request context for logging and error handling.
275
- * @returns A promise resolving with the JSON response from the OpenRouter API.
276
- * @throws {McpError} If the service is not ready, or if the API call fails.
277
- */
278
226
  async listModels(context) {
279
227
  const operation = "OpenRouterProvider.listModels";
280
- this.checkReady(operation, context);
281
- logger.info(`[${operation}] Request received`, context);
282
228
  return await ErrorHandler.tryCatch(async () => {
283
- try {
284
- const response = await fetch("https://openrouter.ai/api/v1/models", {
285
- method: "GET",
286
- headers: {
287
- "Content-Type": "application/json",
288
- },
289
- });
290
- if (!response.ok) {
291
- const errorBody = await response.text();
292
- const errorDetails = {
293
- providerStatus: response.status,
294
- providerMessage: errorBody,
295
- };
296
- logger.error(`[${operation}] Failed to list models`, {
297
- ...context,
298
- ...errorDetails,
299
- });
300
- throw new McpError(BaseErrorCode.INTERNAL_ERROR, `OpenRouter list models API request failed with status ${response.status}.`, errorDetails);
301
- }
302
- const models = await response.json();
303
- logger.info(`[${operation}] Successfully listed models`, context);
304
- return models;
305
- }
306
- catch (error) {
307
- logger.error(`[${operation}] Error listing models`, {
308
- ...context,
309
- error: error.message,
310
- });
311
- if (error instanceof McpError) {
312
- throw error;
313
- }
314
- throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, `Network or unexpected error listing OpenRouter models: ${error.message}`, { cause: error });
315
- }
316
- }, {
317
- operation,
318
- context,
319
- errorCode: BaseErrorCode.INTERNAL_ERROR,
320
- });
229
+ this.checkReady(operation, context);
230
+ const models = await _listModelsLogic(context);
231
+ logger.info(`[${operation}] Successfully listed models`, context);
232
+ return models;
233
+ }, { operation, context });
321
234
  }
322
235
  }
323
- /**
324
- * Singleton instance of the `OpenRouterProvider`.
325
- * Initialized with the OpenRouter API key from application configuration.
326
- */
327
- const openRouterProviderInstance = new OpenRouterProvider(undefined);
236
+ const openRouterProviderInstance = new OpenRouterProvider();
328
237
  export { openRouterProviderInstance as openRouterProvider };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-ts-template",
3
- "version": "1.5.1",
4
- "description": "TypeScript template for building Model Context Protocol (MCP) Servers & Clients. Features extensive utilities (logger, requestContext, etc.), STDIO & Streamable HTTP (with authMiddleware), examples, and type safety. Ideal starting point for creating production-ready MCP Servers & Clients.",
3
+ "version": "1.5.3",
4
+ "description": "Production-ready TypeScript template for building robust Model Context Protocol (MCP) Servers & Clients. Features extensive utilities, service integrations (OpenRouter, DuckDB), advanced authentication (OAuth 2.1, JWT), and both STDIO & Hono-based HTTP transports.",
5
5
  "main": "dist/index.js",
6
6
  "files": [
7
7
  "dist"
@@ -35,14 +35,14 @@
35
35
  "db:duckdb-example": "MCP_LOG_LEVEL=debug tsc && node dist/storage/duckdbExample.js"
36
36
  },
37
37
  "dependencies": {
38
- "@duckdb/node-api": "^1.3.0-alpha.21",
38
+ "@duckdb/node-api": "^1.3.1-alpha.22",
39
39
  "@hono/node-server": "^1.14.4",
40
40
  "@modelcontextprotocol/sdk": "^1.12.3",
41
41
  "@supabase/supabase-js": "^2.50.0",
42
- "@types/jsonwebtoken": "^9.0.9",
43
- "@types/node": "^24.0.1",
42
+ "@types/jsonwebtoken": "^9.0.10",
43
+ "@types/node": "^24.0.3",
44
44
  "@types/sanitize-html": "^2.16.0",
45
- "@types/validator": "13.15.1",
45
+ "@types/validator": "13.15.2",
46
46
  "chrono-node": "^2.8.0",
47
47
  "dotenv": "^16.5.0",
48
48
  "hono": "^4.7.11",
@@ -50,7 +50,7 @@
50
50
  "jose": "^6.0.11",
51
51
  "jsonwebtoken": "^9.0.2",
52
52
  "node-fetch": "^3.3.2",
53
- "openai": "^5.3.0",
53
+ "openai": "^5.5.0",
54
54
  "partial-json": "^0.1.7",
55
55
  "sanitize-html": "^2.17.0",
56
56
  "tiktoken": "^1.0.21",
@@ -59,21 +59,28 @@
59
59
  "validator": "13.15.15",
60
60
  "winston": "^3.17.0",
61
61
  "winston-transport": "^4.9.0",
62
- "zod": "^3.25.64"
62
+ "zod": "^3.25.67"
63
63
  },
64
64
  "keywords": [
65
65
  "typescript",
66
66
  "template",
67
- "MCP",
67
+ "mcp",
68
68
  "model-context-protocol",
69
- "LLM",
70
- "AI-integration",
69
+ "architecture",
70
+ "error-handling",
71
+ "llm",
72
+ "ai-integration",
71
73
  "mcp-server",
72
74
  "mcp-client",
73
- "mcp-template",
75
+ "hono",
74
76
  "stdio",
75
- "streamable-http",
76
- "authentication"
77
+ "http",
78
+ "authentication",
79
+ "oauth",
80
+ "jwt",
81
+ "openrouter",
82
+ "duckdb",
83
+ "zod"
77
84
  ],
78
85
  "author": "cyanheads <casey@caseyjhand.com> (https://github.com/cyanheads/mcp-ts-template#readme)",
79
86
  "license": "Apache-2.0",