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 +13 -3
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +16 -0
- package/dist/mcp-server/tools/catFactFetcher/registration.js +7 -0
- package/dist/mcp-server/transports/authentication/authContext.d.ts +33 -0
- package/dist/mcp-server/transports/authentication/authContext.js +24 -0
- package/dist/mcp-server/transports/authentication/authMiddleware.d.ts +0 -7
- package/dist/mcp-server/transports/authentication/authMiddleware.js +8 -5
- package/dist/mcp-server/transports/authentication/authUtils.d.ts +18 -0
- package/dist/mcp-server/transports/authentication/authUtils.js +45 -0
- package/dist/mcp-server/transports/authentication/oauthMiddleware.d.ts +24 -0
- package/dist/mcp-server/transports/authentication/oauthMiddleware.js +109 -0
- package/dist/mcp-server/transports/authentication/types.d.ts +17 -0
- package/dist/mcp-server/transports/authentication/types.js +5 -0
- package/dist/mcp-server/transports/httpTransport.js +61 -6
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://github.com/modelcontextprotocol/typescript-sdk)
|
|
5
5
|
[](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/changelog.mdx)
|
|
6
|
-
[](./CHANGELOG.md)
|
|
7
7
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
8
8
|
[](https://github.com/cyanheads/mcp-ts-template/issues)
|
|
9
9
|
[](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`.
|
|
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
|
|
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
|
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/config/index.js
CHANGED
|
@@ -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:
|
|
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:
|
|
115
|
-
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
|
+
}
|
|
@@ -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
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
"@supabase/supabase-js": "^2.50.0",
|
|
42
42
|
"@types/jsonwebtoken": "^9.0.9",
|
|
43
|
-
"@types/node": "^
|
|
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.
|
|
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.
|
|
66
|
+
"zod": "^3.25.62"
|
|
66
67
|
},
|
|
67
68
|
"keywords": [
|
|
68
69
|
"typescript",
|