mcp-ts-template 1.4.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.1-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.4.8-blue.svg)](./CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/Version-1.5.0-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)
@@ -91,7 +91,8 @@ Get the example server running in minutes:
91
91
 
92
92
  5. **Run the Example Server:**
93
93
 
94
- - **Via Stdio (Default):** Many MCP host applications will run this automatically using `stdio`. To run manually for testing:
94
+ - **Via Stdio (Default):** Many MCP host applications will run this automatically using `stdio`.
95
+ To run manually for testing:
95
96
  ```bash
96
97
  npm start
97
98
  # or 'npm run start:stdio'
@@ -120,13 +121,22 @@ Configure the MCP server's behavior using these environment variables:
120
121
  | `LOGS_DIR` | Directory for log files. | `logs/` (in project root) |
121
122
  | `NODE_ENV` | Runtime environment (`development`, `production`). | `development` |
122
123
  | `MCP_AUTH_SECRET_KEY` | **Required for HTTP transport.** Secret key (min 32 chars) for signing/verifying auth tokens (JWT). | (none - **MUST be set in production**) |
124
+ | `MCP_AUTH_MODE` | Authentication mode: `jwt` (default) or `oauth`. | `jwt` |
125
+ | `OAUTH_ISSUER_URL` | **Required for `oauth` mode.** The issuer URL of your authorization server. | (none) |
126
+ | `OAUTH_AUDIENCE` | **Required for `oauth` mode.** The audience identifier for this MCP server. | (none) |
127
+ | `OAUTH_JWKS_URI` | **Optional for `oauth` mode.** The JWKS endpoint URL. If omitted, it's discovered from the issuer. | (none) |
123
128
  | `OPENROUTER_API_KEY` | API key for OpenRouter.ai service. Optional, but service will be unconfigured without it. | (none) |
124
129
  | `LLM_DEFAULT_MODEL` | Default model to use for LLM calls via OpenRouter. | `google/gemini-2.5-flash-preview-05-20` |
125
130
  | `LLM_DEFAULT_TEMPERATURE` | Default temperature for LLM calls (0-2). Optional. | (none) |
126
131
 
127
132
  **Note on HTTP Port Retries:** If the `MCP_HTTP_PORT` is busy, the server automatically tries the next port (up to 15 times).
128
133
 
129
- **Security Note for HTTP Transport:** When using `MCP_TRANSPORT_TYPE=http`, authentication is **mandatory** as per the MCP specification. This template includes JWT-based authentication middleware (`src/mcp-server/transports/authentication/authMiddleware.ts`). You **MUST** set a strong, unique `MCP_AUTH_SECRET_KEY` in your production environment for this security mechanism to function correctly. Failure to do so will result in bypassed authentication checks in development and fatal errors in production.
134
+ **Security Note for HTTP Transport:** When using `MCP_TRANSPORT_TYPE=http`, authentication is **mandatory** as per the MCP specification. This template supports two modes via `MCP_AUTH_MODE`:
135
+
136
+ - **`jwt` (default):** A simple, self-contained JWT mode ideal for development. It requires the `MCP_AUTH_SECRET_KEY` to be set for signing and verifying tokens.
137
+ - **`oauth`:** A production-ready OAuth 2.1 mode where the server validates Bearer tokens from an external Authorization Server. This requires `OAUTH_ISSUER_URL` and `OAUTH_AUDIENCE` to be configured.
138
+
139
+ You **MUST** configure one of these modes for the security mechanism to function correctly when using the HTTP transport.
130
140
 
131
141
  ### 🔌 Client Configuration
132
142
 
@@ -44,6 +44,14 @@ export declare const config: {
44
44
  mcpAllowedOrigins: string[] | undefined;
45
45
  /** Auth secret key (JWTs, http transport). From `MCP_AUTH_SECRET_KEY`. CRITICAL. */
46
46
  mcpAuthSecretKey: string | undefined;
47
+ /** The authentication mode ('jwt' or 'oauth'). From `MCP_AUTH_MODE`. */
48
+ mcpAuthMode: "jwt" | "oauth";
49
+ /** OAuth 2.1 Issuer URL. From `OAUTH_ISSUER_URL`. */
50
+ oauthIssuerUrl: string | undefined;
51
+ /** OAuth 2.1 JWKS URI. From `OAUTH_JWKS_URI`. */
52
+ oauthJwksUri: string | undefined;
53
+ /** OAuth 2.1 Audience. From `OAUTH_AUDIENCE`. */
54
+ oauthAudience: string | undefined;
47
55
  /** OpenRouter App URL. From `OPENROUTER_APP_URL`. Default: "http://localhost:3000". */
48
56
  openrouterAppUrl: string;
49
57
  /** OpenRouter App Name. From `OPENROUTER_APP_NAME`. Defaults to `mcpServerName`. */
@@ -95,6 +95,14 @@ const EnvSchema = z.object({
95
95
  .string()
96
96
  .min(32, "MCP_AUTH_SECRET_KEY must be at least 32 characters long for security reasons.")
97
97
  .optional(),
98
+ /** The authentication mode to use. 'jwt' for internal simple JWTs, 'oauth' for OAuth 2.1. Default: 'jwt'. */
99
+ MCP_AUTH_MODE: z.enum(["jwt", "oauth"]).default("jwt"),
100
+ /** The expected issuer URL for OAuth 2.1 access tokens. CRITICAL for validation. */
101
+ OAUTH_ISSUER_URL: z.string().url().optional(),
102
+ /** The JWKS (JSON Web Key Set) URI for the OAuth 2.1 provider. If not provided, it's often discoverable from the issuer URL. */
103
+ OAUTH_JWKS_URI: z.string().url().optional(),
104
+ /** The audience claim for the OAuth 2.1 access tokens. This server will reject tokens not intended for it. */
105
+ OAUTH_AUDIENCE: z.string().optional(),
98
106
  /** Optional. Application URL for OpenRouter integration. */
99
107
  OPENROUTER_APP_URL: z
100
108
  .string()
@@ -253,6 +261,14 @@ export const config = {
253
261
  .filter(Boolean),
254
262
  /** Auth secret key (JWTs, http transport). From `MCP_AUTH_SECRET_KEY`. CRITICAL. */
255
263
  mcpAuthSecretKey: env.MCP_AUTH_SECRET_KEY,
264
+ /** The authentication mode ('jwt' or 'oauth'). From `MCP_AUTH_MODE`. */
265
+ mcpAuthMode: env.MCP_AUTH_MODE,
266
+ /** OAuth 2.1 Issuer URL. From `OAUTH_ISSUER_URL`. */
267
+ oauthIssuerUrl: env.OAUTH_ISSUER_URL,
268
+ /** OAuth 2.1 JWKS URI. From `OAUTH_JWKS_URI`. */
269
+ oauthJwksUri: env.OAUTH_JWKS_URI,
270
+ /** OAuth 2.1 Audience. From `OAUTH_AUDIENCE`. */
271
+ oauthAudience: env.OAUTH_AUDIENCE,
256
272
  /** OpenRouter App URL. From `OPENROUTER_APP_URL`. Default: "http://localhost:3000". */
257
273
  openrouterAppUrl: env.OPENROUTER_APP_URL || "http://localhost:3000",
258
274
  /** OpenRouter App Name. From `OPENROUTER_APP_NAME`. Defaults to `mcpServerName`. */
@@ -33,6 +33,13 @@ export const registerCatFactFetcherTool = async (server) => {
33
33
  inputSummary: { maxLength: params.maxLength },
34
34
  });
35
35
  logger.debug(`Handling '${toolName}' tool request.`, handlerContext);
36
+ // --- EXAMPLE: Scope-Based Authorization ---
37
+ // To protect this tool with OAuth 2.1 scopes, uncomment the following line.
38
+ // This requires the MCP_AUTH_MODE to be 'oauth' and the client's access token
39
+ // to contain the specified scope(s).
40
+ //
41
+ // withRequiredScopes(['facts:read']);
42
+ //
36
43
  return await ErrorHandler.tryCatch(async () => {
37
44
  const responsePayload = await processCatFactFetcher(params, handlerContext);
38
45
  logger.debug(`'${toolName}' tool processed successfully. Preparing result.`, handlerContext);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @fileoverview Defines the AsyncLocalStorage context for authentication information.
3
+ * This module provides a mechanism to store and retrieve authentication details
4
+ * (like scopes and client ID) across asynchronous operations, making it available
5
+ * from the middleware layer down to the tool and resource handlers without
6
+ * drilling props.
7
+ *
8
+ * @module src/mcp-server/transports/authentication/authContext
9
+ */
10
+ import { AsyncLocalStorage } from "async_hooks";
11
+ import type { AuthInfo } from "./types.js";
12
+ /**
13
+ * Defines the structure of the store used within the AsyncLocalStorage.
14
+ * It holds the authentication information for the current request context.
15
+ */
16
+ interface AuthStore {
17
+ authInfo: AuthInfo;
18
+ }
19
+ /**
20
+ * An instance of AsyncLocalStorage to hold the authentication context (`AuthStore`).
21
+ * This allows `authInfo` to be accessible throughout the async call chain of a request
22
+ * after being set in the authentication middleware.
23
+ *
24
+ * @example
25
+ * // In middleware:
26
+ * await authContext.run({ authInfo }, next);
27
+ *
28
+ * // In a deeper handler:
29
+ * const store = authContext.getStore();
30
+ * const scopes = store?.authInfo.scopes;
31
+ */
32
+ export declare const authContext: AsyncLocalStorage<AuthStore>;
33
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @fileoverview Defines the AsyncLocalStorage context for authentication information.
3
+ * This module provides a mechanism to store and retrieve authentication details
4
+ * (like scopes and client ID) across asynchronous operations, making it available
5
+ * from the middleware layer down to the tool and resource handlers without
6
+ * drilling props.
7
+ *
8
+ * @module src/mcp-server/transports/authentication/authContext
9
+ */
10
+ import { AsyncLocalStorage } from "async_hooks";
11
+ /**
12
+ * An instance of AsyncLocalStorage to hold the authentication context (`AuthStore`).
13
+ * This allows `authInfo` to be accessible throughout the async call chain of a request
14
+ * after being set in the authentication middleware.
15
+ *
16
+ * @example
17
+ * // In middleware:
18
+ * await authContext.run({ authInfo }, next);
19
+ *
20
+ * // In a deeper handler:
21
+ * const store = authContext.getStore();
22
+ * const scopes = store?.authInfo.scopes;
23
+ */
24
+ export const authContext = new AsyncLocalStorage();
@@ -16,14 +16,7 @@
16
16
  * @module src/mcp-server/transports/authentication/authMiddleware
17
17
  */
18
18
  import { HttpBindings } from "@hono/node-server";
19
- import { type AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
20
19
  import { Context, Next } from "hono";
21
- export type { AuthInfo };
22
- declare module "http" {
23
- interface IncomingMessage {
24
- auth?: AuthInfo;
25
- }
26
- }
27
20
  /**
28
21
  * Hono middleware for verifying JWT Bearer token authentication.
29
22
  * It attaches authentication info to `c.env.incoming.auth` for SDK compatibility with the node server.
@@ -18,6 +18,7 @@
18
18
  import jwt from "jsonwebtoken";
19
19
  import { config, environment } from "../../../config/index.js";
20
20
  import { logger, requestContextService } from "../../../utils/index.js";
21
+ import { authContext } from "./authContext.js";
21
22
  // Startup Validation: Validate secret key presence on module load.
22
23
  if (environment === "production" && !config.mcpAuthSecretKey) {
23
24
  logger.fatal("CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.");
@@ -47,11 +48,12 @@ export async function mcpAuthMiddleware(c, next) {
47
48
  clientId: "dev-client-id",
48
49
  scopes: ["dev-scope"],
49
50
  };
51
+ const authInfo = reqWithAuth.auth;
50
52
  logger.debug("Dev mode auth object created.", {
51
53
  ...context,
52
- authDetails: reqWithAuth.auth,
54
+ authDetails: authInfo,
53
55
  });
54
- return await next();
56
+ return await authContext.run({ authInfo }, next);
55
57
  }
56
58
  else {
57
59
  logger.error("FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.", context);
@@ -108,13 +110,14 @@ export async function mcpAuthMiddleware(c, next) {
108
110
  scopes: scopesFromToken,
109
111
  };
110
112
  const subClaimForLogging = typeof decoded.sub === "string" ? decoded.sub : undefined;
113
+ const authInfo = reqWithAuth.auth;
111
114
  logger.debug("JWT verified successfully. AuthInfo attached to request.", {
112
115
  ...context,
113
116
  mcpSessionIdContext: subClaimForLogging,
114
- clientId: reqWithAuth.auth.clientId,
115
- scopes: reqWithAuth.auth.scopes,
117
+ clientId: authInfo.clientId,
118
+ scopes: authInfo.scopes,
116
119
  });
117
- await next();
120
+ await authContext.run({ authInfo }, next);
118
121
  }
119
122
  catch (error) {
120
123
  let errorMessage = "Invalid token";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @fileoverview Provides utility functions for authorization, specifically for
3
+ * checking token scopes against required permissions for a given operation.
4
+ * @module src/mcp-server/transports/authentication/authUtils
5
+ */
6
+ /**
7
+ * Checks if the current authentication context contains all the specified scopes.
8
+ * This function is designed to be called within tool or resource handlers to
9
+ * enforce scope-based access control. It retrieves the authentication information
10
+ * from `authContext` (AsyncLocalStorage).
11
+ *
12
+ * @param requiredScopes - An array of scope strings that are mandatory for the operation.
13
+ * @throws {McpError} Throws an error with `BaseErrorCode.INTERNAL_ERROR` if the
14
+ * authentication context is missing, which indicates a server configuration issue.
15
+ * @throws {McpError} Throws an error with `BaseErrorCode.FORBIDDEN` if one or
16
+ * more required scopes are not present in the validated token.
17
+ */
18
+ export declare function withRequiredScopes(requiredScopes: string[]): void;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @fileoverview Provides utility functions for authorization, specifically for
3
+ * checking token scopes against required permissions for a given operation.
4
+ * @module src/mcp-server/transports/authentication/authUtils
5
+ */
6
+ import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
7
+ import { logger, requestContextService } from "../../../utils/index.js";
8
+ import { authContext } from "./authContext.js";
9
+ /**
10
+ * Checks if the current authentication context contains all the specified scopes.
11
+ * This function is designed to be called within tool or resource handlers to
12
+ * enforce scope-based access control. It retrieves the authentication information
13
+ * from `authContext` (AsyncLocalStorage).
14
+ *
15
+ * @param requiredScopes - An array of scope strings that are mandatory for the operation.
16
+ * @throws {McpError} Throws an error with `BaseErrorCode.INTERNAL_ERROR` if the
17
+ * authentication context is missing, which indicates a server configuration issue.
18
+ * @throws {McpError} Throws an error with `BaseErrorCode.FORBIDDEN` if one or
19
+ * more required scopes are not present in the validated token.
20
+ */
21
+ export function withRequiredScopes(requiredScopes) {
22
+ const store = authContext.getStore();
23
+ if (!store || !store.authInfo) {
24
+ // This is a server-side logic error; the auth middleware should always populate this.
25
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Authentication context is missing. This indicates a server configuration error.", requestContextService.createRequestContext({
26
+ operation: "withRequiredScopesCheck",
27
+ error: "AuthStore not found in AsyncLocalStorage.",
28
+ }));
29
+ }
30
+ const { scopes: grantedScopes, clientId } = store.authInfo;
31
+ const grantedScopeSet = new Set(grantedScopes);
32
+ const missingScopes = requiredScopes.filter((scope) => !grantedScopeSet.has(scope));
33
+ if (missingScopes.length > 0) {
34
+ const context = requestContextService.createRequestContext({
35
+ operation: "withRequiredScopesCheck",
36
+ required: requiredScopes,
37
+ granted: grantedScopes,
38
+ missing: missingScopes,
39
+ clientId: clientId,
40
+ subject: store.authInfo.subject,
41
+ });
42
+ logger.warning("Authorization failed: Missing required scopes.", context);
43
+ throw new McpError(BaseErrorCode.FORBIDDEN, `Insufficient permissions. Missing required scopes: ${missingScopes.join(", ")}`, { requiredScopes, missingScopes });
44
+ }
45
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @fileoverview Hono middleware for OAuth 2.1 Bearer Token validation.
3
+ * This middleware extracts a JWT from the Authorization header, validates it against
4
+ * a remote JWKS (JSON Web Key Set), and checks its issuer and audience claims.
5
+ * On success, it populates an AuthInfo object and stores it in an AsyncLocalStorage
6
+ * context for use in downstream handlers.
7
+ *
8
+ * @module src/mcp-server/transports/authentication/oauthMiddleware
9
+ */
10
+ import { HttpBindings } from "@hono/node-server";
11
+ import { Context, Next } from "hono";
12
+ /**
13
+ * Hono middleware for verifying OAuth 2.1 JWT Bearer tokens.
14
+ * It validates the token and uses AsyncLocalStorage to pass auth info.
15
+ * @param c - The Hono context object.
16
+ * @param next - The function to call to proceed to the next middleware.
17
+ */
18
+ export declare function oauthMiddleware(c: Context<{
19
+ Bindings: HttpBindings;
20
+ }>, next: Next): Promise<(Response & import("hono").TypedResponse<{
21
+ error: string;
22
+ }, 500, "json">) | (Response & import("hono").TypedResponse<{
23
+ error: string;
24
+ }, 401, "json">) | undefined>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @fileoverview Hono middleware for OAuth 2.1 Bearer Token validation.
3
+ * This middleware extracts a JWT from the Authorization header, validates it against
4
+ * a remote JWKS (JSON Web Key Set), and checks its issuer and audience claims.
5
+ * On success, it populates an AuthInfo object and stores it in an AsyncLocalStorage
6
+ * context for use in downstream handlers.
7
+ *
8
+ * @module src/mcp-server/transports/authentication/oauthMiddleware
9
+ */
10
+ import { createRemoteJWKSet, jwtVerify } from "jose";
11
+ import { config } from "../../../config/index.js";
12
+ import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
13
+ import { ErrorHandler } from "../../../utils/internal/errorHandler.js";
14
+ import { logger, requestContextService } from "../../../utils/index.js";
15
+ import { authContext } from "./authContext.js";
16
+ // --- Startup Validation ---
17
+ // Ensures that necessary OAuth configuration is present when the mode is 'oauth'.
18
+ if (config.mcpAuthMode === "oauth") {
19
+ if (!config.oauthIssuerUrl) {
20
+ throw new Error("OAUTH_ISSUER_URL must be set when MCP_AUTH_MODE is 'oauth'");
21
+ }
22
+ if (!config.oauthAudience) {
23
+ throw new Error("OAUTH_AUDIENCE must be set when MCP_AUTH_MODE is 'oauth'");
24
+ }
25
+ logger.info("OAuth 2.1 mode enabled. Verifying tokens against issuer.", requestContextService.createRequestContext({
26
+ issuer: config.oauthIssuerUrl,
27
+ audience: config.oauthAudience,
28
+ }));
29
+ }
30
+ // --- JWKS Client Initialization ---
31
+ // The remote JWK set is fetched and cached to avoid network calls on every request.
32
+ let jwks;
33
+ if (config.mcpAuthMode === "oauth" && config.oauthIssuerUrl) {
34
+ try {
35
+ const jwksUrl = new URL(config.oauthJwksUri ||
36
+ `${config.oauthIssuerUrl.replace(/\/$/, "")}/.well-known/jwks.json`);
37
+ jwks = createRemoteJWKSet(jwksUrl, {
38
+ cooldownDuration: 300000, // 5 minutes
39
+ timeoutDuration: 5000, // 5 seconds
40
+ });
41
+ logger.info(`JWKS client initialized for URL: ${jwksUrl.href}`, requestContextService.createRequestContext({
42
+ operation: "oauthMiddlewareSetup",
43
+ }));
44
+ }
45
+ catch (error) {
46
+ logger.fatal("Failed to initialize JWKS client.", error, requestContextService.createRequestContext({
47
+ operation: "oauthMiddlewareSetup",
48
+ }));
49
+ // Prevent server from starting if JWKS setup fails in oauth mode
50
+ process.exit(1);
51
+ }
52
+ }
53
+ /**
54
+ * Hono middleware for verifying OAuth 2.1 JWT Bearer tokens.
55
+ * It validates the token and uses AsyncLocalStorage to pass auth info.
56
+ * @param c - The Hono context object.
57
+ * @param next - The function to call to proceed to the next middleware.
58
+ */
59
+ export async function oauthMiddleware(c, next) {
60
+ const context = requestContextService.createRequestContext({
61
+ operation: "oauthMiddleware",
62
+ httpMethod: c.req.method,
63
+ httpPath: c.req.path,
64
+ });
65
+ if (!jwks) {
66
+ // This should not happen if startup validation is correct, but it's a safeguard.
67
+ const error = new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OAuth middleware is active, but JWKS client is not initialized.", context);
68
+ ErrorHandler.handleError(error, { operation: "oauthMiddleware", context });
69
+ return c.json({ error: "Server configuration error." }, 500);
70
+ }
71
+ const authHeader = c.req.header("Authorization");
72
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
73
+ return c.json({ error: "Unauthorized: Missing or invalid token format." }, 401);
74
+ }
75
+ const token = authHeader.substring(7);
76
+ try {
77
+ const { payload } = await jwtVerify(token, jwks, {
78
+ issuer: config.oauthIssuerUrl,
79
+ audience: config.oauthAudience,
80
+ });
81
+ // The 'scope' claim is typically a space-delimited string in OAuth 2.1.
82
+ const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
83
+ const clientId = typeof payload.client_id === "string" ? payload.client_id : undefined;
84
+ if (!clientId) {
85
+ logger.warning("Authentication failed: OAuth token 'client_id' claim is missing or not a string.", { ...context, jwtPayloadKeys: Object.keys(payload) });
86
+ return c.json({ error: "Unauthorized: Invalid token, missing client identifier." }, 401);
87
+ }
88
+ const authInfo = {
89
+ token,
90
+ clientId,
91
+ scopes,
92
+ subject: typeof payload.sub === "string" ? payload.sub : undefined,
93
+ };
94
+ // Attach to the raw request for potential legacy compatibility and
95
+ // store in AsyncLocalStorage for modern, safe access in handlers.
96
+ c.env.incoming.auth = authInfo;
97
+ await authContext.run({ authInfo }, next);
98
+ }
99
+ catch (error) {
100
+ logger.warning("OAuth token validation failed", {
101
+ ...context,
102
+ errorName: error.name,
103
+ errorMessage: error.message,
104
+ });
105
+ // The `jose` library provides specific error codes like 'ERR_JWT_EXPIRED' or 'ERR_JWS_INVALID'
106
+ const message = `Unauthorized: ${error.message || "Invalid token"}`;
107
+ return c.json({ error: message }, 401);
108
+ }
109
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @fileoverview Shared types for authentication middleware.
3
+ * @module src/mcp-server/transports/authentication/types
4
+ */
5
+ import type { AuthInfo as SdkAuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
6
+ /**
7
+ * Defines the structure for authentication information derived from a token.
8
+ * It extends the base SDK type to include common optional claims.
9
+ */
10
+ export type AuthInfo = SdkAuthInfo & {
11
+ subject?: string;
12
+ };
13
+ declare module "http" {
14
+ interface IncomingMessage {
15
+ auth?: AuthInfo;
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @fileoverview Shared types for authentication middleware.
3
+ * @module src/mcp-server/transports/authentication/types
4
+ */
5
+ export {};
@@ -20,7 +20,8 @@ import { randomUUID } from "node:crypto";
20
20
  import { config } from "../../config/index.js";
21
21
  import { BaseErrorCode, McpError } from "../../types-global/errors.js";
22
22
  import { logger, rateLimiter, requestContextService, } from "../../utils/index.js";
23
- import { mcpAuthMiddleware, } from "./authentication/authMiddleware.js";
23
+ import { mcpAuthMiddleware } from "./authentication/authMiddleware.js";
24
+ import { oauthMiddleware } from "./authentication/oauthMiddleware.js";
24
25
  /**
25
26
  * The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
26
27
  * Defaults to 3010 if not specified (default is managed by the config module).
@@ -57,6 +58,28 @@ const MAX_PORT_RETRIES = 15;
57
58
  * @private
58
59
  */
59
60
  const httpTransports = {};
61
+ /**
62
+ * Stores the last activity timestamp for each session, keyed by session ID.
63
+ * Used for garbage collecting stale/abandoned sessions.
64
+ * @type {Record<string, number>}
65
+ * @private
66
+ */
67
+ const sessionActivity = {};
68
+ /**
69
+ * The timeout period in milliseconds for inactive sessions. If a session has no
70
+ * activity for this duration, it will be considered stale and garbage collected.
71
+ * Defaults to 30 minutes.
72
+ * @constant {number} SESSION_TIMEOUT_MS
73
+ * @private
74
+ */
75
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
76
+ /**
77
+ * The interval in milliseconds at which the session garbage collector runs to
78
+ * clean up stale sessions. Defaults to 1 minute.
79
+ * @constant {number} SESSION_GC_INTERVAL_MS
80
+ * @private
81
+ */
82
+ const SESSION_GC_INTERVAL_MS = 60 * 1000; // 1 minute
60
83
  /**
61
84
  * Proactively checks if a specific network port is already in use.
62
85
  * @param port - The port number to check.
@@ -184,6 +207,24 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
184
207
  component: "HttpTransportSetup",
185
208
  });
186
209
  logger.debug("Setting up Hono app for HTTP transport...", transportContext);
210
+ // Start the session garbage collector
211
+ setInterval(() => {
212
+ const now = Date.now();
213
+ const gcContext = requestContextService.createRequestContext({
214
+ operation: "SessionGarbageCollector",
215
+ });
216
+ logger.debug("Running session garbage collector...", gcContext);
217
+ for (const sessionId in sessionActivity) {
218
+ if (now - sessionActivity[sessionId] > SESSION_TIMEOUT_MS) {
219
+ logger.info(`Session ${sessionId} timed out due to inactivity. Cleaning up.`, { ...gcContext, sessionId });
220
+ const transport = httpTransports[sessionId];
221
+ if (transport) {
222
+ transport.close(); // This will trigger the onclose handler to delete it from httpTransports
223
+ }
224
+ delete sessionActivity[sessionId];
225
+ }
226
+ }
227
+ }, SESSION_GC_INTERVAL_MS);
187
228
  app.use("*", cors({
188
229
  origin: config.mcpAllowedOrigins || [],
189
230
  allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
@@ -211,9 +252,9 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
211
252
  await next();
212
253
  });
213
254
  app.use(MCP_ENDPOINT_PATH, async (c, next) => {
214
- const rateLimitKey = c.req.header("x-forwarded-for") ||
215
- c.req.header("host") ||
216
- "unknown_ip_for_rate_limit";
255
+ const xff = c.req.header("x-forwarded-for");
256
+ const clientIp = xff ? xff.split(",")[0].trim() : "unknown_ip";
257
+ const rateLimitKey = clientIp || c.req.header("host") || "unknown_ip_for_rate_limit";
217
258
  const context = requestContextService.createRequestContext({
218
259
  operation: "httpRateLimitCheck",
219
260
  ipAddress: rateLimitKey,
@@ -248,7 +289,13 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
248
289
  }
249
290
  }
250
291
  });
251
- app.use(MCP_ENDPOINT_PATH, mcpAuthMiddleware);
292
+ // Use the appropriate authentication middleware based on config
293
+ if (config.mcpAuthMode === "oauth") {
294
+ app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
295
+ }
296
+ else {
297
+ app.use(MCP_ENDPOINT_PATH, mcpAuthMiddleware);
298
+ }
252
299
  app.post(MCP_ENDPOINT_PATH, async (c) => {
253
300
  const basePostContext = requestContextService.createRequestContext({
254
301
  ...transportContext,
@@ -269,6 +316,9 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
269
316
  sessionId,
270
317
  });
271
318
  let transport = sessionId ? httpTransports[sessionId] : undefined;
319
+ if (transport && sessionId) {
320
+ sessionActivity[sessionId] = Date.now(); // Update activity timestamp
321
+ }
272
322
  logger.debug(`Found existing transport for session ID: ${!!transport}`, {
273
323
  ...basePostContext,
274
324
  sessionId,
@@ -284,7 +334,7 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
284
334
  if (transport) {
285
335
  logger.warning("Received InitializeRequest on an existing session ID. Closing old session and creating new.", { ...basePostContext, sessionId });
286
336
  await transport.close();
287
- delete httpTransports[sessionId];
337
+ // onclose handler will delete from httpTransports and sessionActivity
288
338
  }
289
339
  logger.info("Handling Initialize Request: Creating new session...", {
290
340
  ...basePostContext,
@@ -299,6 +349,7 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
299
349
  onsessioninitialized: (newId) => {
300
350
  logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId });
301
351
  httpTransports[newId] = transport;
352
+ sessionActivity[newId] = Date.now(); // Initialize activity timestamp
302
353
  logger.info(`HTTP Session created: ${newId}`, {
303
354
  ...basePostContext,
304
355
  newSessionId: newId,
@@ -310,6 +361,7 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
310
361
  if (closedSessionId) {
311
362
  logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId });
312
363
  delete httpTransports[closedSessionId];
364
+ delete sessionActivity[closedSessionId]; // Clean up activity tracker
313
365
  logger.info(`HTTP Session closed: ${closedSessionId}`, {
314
366
  ...basePostContext,
315
367
  closedSessionId,
@@ -388,6 +440,9 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
388
440
  sessionId,
389
441
  });
390
442
  const transport = sessionId ? httpTransports[sessionId] : undefined;
443
+ if (transport && sessionId) {
444
+ sessionActivity[sessionId] = Date.now(); // Update activity timestamp
445
+ }
391
446
  logger.debug(`Found existing transport for session ID: ${!!transport}`, {
392
447
  ...baseSessionReqContext,
393
448
  sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-ts-template",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
4
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.",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -35,12 +35,12 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@duckdb/node-api": "^1.3.0-alpha.21",
38
- "@hono/node-server": "^1.14.3",
38
+ "@hono/node-server": "^1.14.4",
39
39
  "@modelcontextprotocol/sdk": "^1.12.1",
40
40
  "@node-oauth/oauth2-server": "^5.2.0",
41
- "@supabase/supabase-js": "^2.49.10",
41
+ "@supabase/supabase-js": "^2.50.0",
42
42
  "@types/jsonwebtoken": "^9.0.9",
43
- "@types/node": "^22.15.30",
43
+ "@types/node": "^24.0.1",
44
44
  "@types/sanitize-html": "^2.16.0",
45
45
  "@types/validator": "13.15.1",
46
46
  "bcryptjs": "^3.0.2",
@@ -50,8 +50,9 @@
50
50
  "dotenv": "^16.5.0",
51
51
  "hono": "^4.7.11",
52
52
  "ignore": "^7.0.5",
53
+ "jose": "^6.0.11",
53
54
  "jsonwebtoken": "^9.0.2",
54
- "openai": "^5.1.1",
55
+ "openai": "^5.3.0",
55
56
  "partial-json": "^0.1.7",
56
57
  "pg": "^8.16.0",
57
58
  "sanitize-html": "^2.17.0",
@@ -62,7 +63,7 @@
62
63
  "winston": "^3.17.0",
63
64
  "winston-daily-rotate-file": "^5.0.0",
64
65
  "yargs": "^18.0.0",
65
- "zod": "^3.25.51"
66
+ "zod": "^3.25.62"
66
67
  },
67
68
  "keywords": [
68
69
  "typescript",