@cyanheads/git-mcp-server 2.1.3 → 2.1.5

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.
Files changed (37) hide show
  1. package/README.md +10 -8
  2. package/dist/config/index.js +10 -2
  3. package/dist/mcp-server/server.js +33 -32
  4. package/dist/mcp-server/tools/gitAdd/logic.js +18 -45
  5. package/dist/mcp-server/tools/gitBranch/logic.js +39 -34
  6. package/dist/mcp-server/tools/gitCheckout/logic.js +17 -12
  7. package/dist/mcp-server/tools/gitCherryPick/logic.js +22 -15
  8. package/dist/mcp-server/tools/gitClean/logic.js +11 -8
  9. package/dist/mcp-server/tools/gitClone/logic.js +15 -11
  10. package/dist/mcp-server/tools/gitCommit/logic.js +29 -65
  11. package/dist/mcp-server/tools/gitDiff/logic.js +29 -14
  12. package/dist/mcp-server/tools/gitFetch/logic.js +13 -12
  13. package/dist/mcp-server/tools/gitInit/logic.js +12 -9
  14. package/dist/mcp-server/tools/gitLog/logic.js +17 -30
  15. package/dist/mcp-server/tools/gitMerge/logic.js +17 -12
  16. package/dist/mcp-server/tools/gitPull/logic.js +13 -14
  17. package/dist/mcp-server/tools/gitPush/logic.js +19 -21
  18. package/dist/mcp-server/tools/gitRebase/logic.js +29 -20
  19. package/dist/mcp-server/tools/gitRemote/logic.js +15 -15
  20. package/dist/mcp-server/tools/gitReset/logic.js +11 -10
  21. package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +6 -4
  22. package/dist/mcp-server/tools/gitShow/logic.js +9 -8
  23. package/dist/mcp-server/tools/gitStash/logic.js +16 -17
  24. package/dist/mcp-server/tools/gitStatus/logic.js +10 -8
  25. package/dist/mcp-server/tools/gitTag/logic.js +15 -15
  26. package/dist/mcp-server/tools/gitWorktree/logic.js +54 -38
  27. package/dist/mcp-server/transports/auth/core/authContext.js +24 -0
  28. package/dist/mcp-server/transports/auth/core/authTypes.js +5 -0
  29. package/dist/mcp-server/transports/auth/core/authUtils.js +45 -0
  30. package/dist/mcp-server/transports/auth/index.js +9 -0
  31. package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.js +149 -0
  32. package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.js +127 -0
  33. package/dist/mcp-server/transports/httpErrorHandler.js +73 -0
  34. package/dist/mcp-server/transports/httpTransport.js +149 -495
  35. package/dist/mcp-server/transports/stdioTransport.js +18 -48
  36. package/package.json +4 -13
  37. package/dist/mcp-server/transports/authentication/authMiddleware.js +0 -167
@@ -1,9 +1,9 @@
1
- import { exec } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import { z } from "zod";
4
4
  import { logger, sanitization } from "../../../utils/index.js";
5
5
  import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
6
- const execAsync = promisify(exec);
6
+ const execFileAsync = promisify(execFile);
7
7
  // Define the BASE input schema for the git_worktree tool using Zod
8
8
  export const GitWorktreeBaseSchema = z.object({
9
9
  path: z
@@ -176,18 +176,19 @@ export async function gitWorktreeLogic(input, context) {
176
176
  throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
177
177
  }
178
178
  try {
179
- let command = `git -C "${targetPath}" worktree `;
179
+ let args;
180
180
  let result;
181
181
  switch (input.mode) {
182
182
  case "list":
183
- command += "list";
184
- if (input.verbose)
185
- command += " --porcelain"; // Use porcelain for structured output
186
- logger.debug(`Executing command: ${command}`, {
183
+ args = ["-C", targetPath, "worktree", "list"];
184
+ if (input.verbose) {
185
+ args.push("--porcelain");
186
+ } // Use porcelain for structured output
187
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
187
188
  ...context,
188
189
  operation,
189
190
  });
190
- const { stdout: listStdout } = await execAsync(command);
191
+ const { stdout: listStdout } = await execFileAsync("git", args);
191
192
  if (input.verbose) {
192
193
  const worktrees = parsePorcelainWorktreeList(listStdout);
193
194
  result = { success: true, mode: "list", worktrees };
@@ -214,21 +215,25 @@ export async function gitWorktreeLogic(input, context) {
214
215
  case "add":
215
216
  // worktreePath is guaranteed by refine
216
217
  const sanitizedWorktreePathAdd = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
217
- command += `add `;
218
- if (input.force)
219
- command += "--force ";
220
- if (input.detach)
221
- command += "--detach ";
222
- if (input.newBranch)
223
- command += `-b "${input.newBranch}" `;
224
- command += `"${sanitizedWorktreePathAdd}"`;
225
- if (input.commitish)
226
- command += ` "${input.commitish}"`;
227
- logger.debug(`Executing command: ${command}`, {
218
+ args = ["-C", targetPath, "worktree", "add"];
219
+ if (input.force) {
220
+ args.push("--force");
221
+ }
222
+ if (input.detach) {
223
+ args.push("--detach");
224
+ }
225
+ if (input.newBranch) {
226
+ args.push("-b", input.newBranch);
227
+ }
228
+ args.push(sanitizedWorktreePathAdd);
229
+ if (input.commitish) {
230
+ args.push(input.commitish);
231
+ }
232
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
228
233
  ...context,
229
234
  operation,
230
235
  });
231
- await execAsync(command);
236
+ await execFileAsync("git", args);
232
237
  // To get the HEAD of the new worktree, we might need another command or parse output if available
233
238
  // For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD`
234
239
  result = {
@@ -243,15 +248,16 @@ export async function gitWorktreeLogic(input, context) {
243
248
  case "remove":
244
249
  // worktreePath is guaranteed by refine
245
250
  const sanitizedWorktreePathRemove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
246
- command += `remove `;
247
- if (input.force)
248
- command += "--force ";
249
- command += `"${sanitizedWorktreePathRemove}"`;
250
- logger.debug(`Executing command: ${command}`, {
251
+ args = ["-C", targetPath, "worktree", "remove"];
252
+ if (input.force) {
253
+ args.push("--force");
254
+ }
255
+ args.push(sanitizedWorktreePathRemove);
256
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
251
257
  ...context,
252
258
  operation,
253
259
  });
254
- const { stdout: removeStdout } = await execAsync(command);
260
+ const { stdout: removeStdout } = await execFileAsync("git", args);
255
261
  result = {
256
262
  success: true,
257
263
  mode: "remove",
@@ -267,12 +273,19 @@ export async function gitWorktreeLogic(input, context) {
267
273
  allowAbsolute: true,
268
274
  rootDir: targetPath,
269
275
  }).sanitizedPath;
270
- command += `move "${sanitizedOldPathMove}" "${sanitizedNewPathMove}"`;
271
- logger.debug(`Executing command: ${command}`, {
276
+ args = [
277
+ "-C",
278
+ targetPath,
279
+ "worktree",
280
+ "move",
281
+ sanitizedOldPathMove,
282
+ sanitizedNewPathMove,
283
+ ];
284
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
272
285
  ...context,
273
286
  operation,
274
287
  });
275
- await execAsync(command);
288
+ await execFileAsync("git", args);
276
289
  result = {
277
290
  success: true,
278
291
  mode: "move",
@@ -282,18 +295,21 @@ export async function gitWorktreeLogic(input, context) {
282
295
  };
283
296
  break;
284
297
  case "prune":
285
- command += "prune ";
286
- if (input.dryRun)
287
- command += "--dry-run ";
288
- if (input.verbose)
289
- command += "--verbose ";
290
- if (input.expire)
291
- command += `--expire "${input.expire}" `;
292
- logger.debug(`Executing command: ${command}`, {
298
+ args = ["-C", targetPath, "worktree", "prune"];
299
+ if (input.dryRun) {
300
+ args.push("--dry-run");
301
+ }
302
+ if (input.verbose) {
303
+ args.push("--verbose");
304
+ }
305
+ if (input.expire) {
306
+ args.push(`--expire=${input.expire}`);
307
+ }
308
+ logger.debug(`Executing command: git ${args.join(" ")}`, {
293
309
  ...context,
294
310
  operation,
295
311
  });
296
- const { stdout: pruneStdout, stderr: pruneStderr } = await execAsync(command);
312
+ const { stdout: pruneStdout, stderr: pruneStderr } = await execFileAsync("git", args);
297
313
  // Prune often outputs to stderr even on success for verbose/dry-run
298
314
  const pruneMessage = pruneStdout.trim() ||
299
315
  pruneStderr.trim() ||
@@ -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/auth/core/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();
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @fileoverview Shared types for authentication middleware.
3
+ * @module src/mcp-server/transports/auth/core/auth.types
4
+ */
5
+ export {};
@@ -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/auth/core/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,9 @@
1
+ /**
2
+ * @fileoverview Barrel file for the auth module.
3
+ * Exports core utilities and middleware strategies for easier imports.
4
+ * @module src/mcp-server/transports/auth/index
5
+ */
6
+ export { authContext } from "./core/authContext.js";
7
+ export { withRequiredScopes } from "./core/authUtils.js";
8
+ export { mcpAuthMiddleware as jwtAuthMiddleware } from "./strategies/jwt/jwtMiddleware.js";
9
+ export { oauthMiddleware } from "./strategies/oauth/oauthMiddleware.js";
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT) for Hono.
3
+ *
4
+ * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
5
+ * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
6
+ * It verifies the token's signature and expiration using the secret key defined
7
+ * in the configuration (`config.mcpAuthSecretKey`).
8
+ *
9
+ * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type
10
+ * is attached to `c.env.incoming.auth`. This direct attachment to the raw Node.js
11
+ * request object is for compatibility with the underlying SDK transport, which is
12
+ * not Hono-context-aware.
13
+ * If the token is missing, invalid, or expired, it throws an `McpError`, which is
14
+ * then handled by the centralized `httpErrorHandler`.
15
+ *
16
+ * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
17
+ * @module src/mcp-server/transports/auth/strategies/jwt/jwtMiddleware
18
+ */
19
+ import { jwtVerify } from "jose";
20
+ import { config, environment } from "../../../../../config/index.js";
21
+ import { logger, requestContextService } from "../../../../../utils/index.js";
22
+ import { BaseErrorCode, McpError } from "../../../../../types-global/errors.js";
23
+ import { authContext } from "../../core/authContext.js";
24
+ // Startup Validation: Validate secret key presence on module load.
25
+ if (config.mcpAuthMode === "jwt") {
26
+ if (environment === "production" && !config.mcpAuthSecretKey) {
27
+ logger.fatal("CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment for JWT auth. Authentication cannot proceed securely.");
28
+ throw new Error("MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.");
29
+ }
30
+ else if (!config.mcpAuthSecretKey) {
31
+ logger.warning("MCP_AUTH_SECRET_KEY is not set. JWT auth middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.");
32
+ }
33
+ }
34
+ /**
35
+ * Hono middleware for verifying JWT Bearer token authentication.
36
+ * It attaches authentication info to `c.env.incoming.auth` for SDK compatibility with the node server.
37
+ */
38
+ export async function mcpAuthMiddleware(c, next) {
39
+ const context = requestContextService.createRequestContext({
40
+ operation: "mcpAuthMiddleware",
41
+ method: c.req.method,
42
+ path: c.req.path,
43
+ });
44
+ logger.debug("Running MCP Authentication Middleware (Bearer Token Validation)...", context);
45
+ const reqWithAuth = c.env.incoming;
46
+ // If JWT auth is not enabled, skip the middleware.
47
+ if (config.mcpAuthMode !== "jwt") {
48
+ return await next();
49
+ }
50
+ // Development Mode Bypass
51
+ if (!config.mcpAuthSecretKey) {
52
+ if (environment !== "production") {
53
+ logger.warning("Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).", context);
54
+ reqWithAuth.auth = {
55
+ token: "dev-mode-placeholder-token",
56
+ clientId: "dev-client-id",
57
+ scopes: ["dev-scope"],
58
+ };
59
+ const authInfo = reqWithAuth.auth;
60
+ logger.debug("Dev mode auth object created.", {
61
+ ...context,
62
+ authDetails: authInfo,
63
+ });
64
+ return await authContext.run({ authInfo }, next);
65
+ }
66
+ else {
67
+ logger.error("FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.", context);
68
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, "Server configuration error: Authentication key missing.");
69
+ }
70
+ }
71
+ const secretKey = new TextEncoder().encode(config.mcpAuthSecretKey);
72
+ const authHeader = c.req.header("Authorization");
73
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
74
+ logger.warning("Authentication failed: Missing or malformed Authorization header (Bearer scheme required).", context);
75
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Missing or invalid authentication token format.");
76
+ }
77
+ const tokenParts = authHeader.split(" ");
78
+ if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) {
79
+ logger.warning("Authentication failed: Malformed Bearer token.", context);
80
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Malformed authentication token.");
81
+ }
82
+ const rawToken = tokenParts[1];
83
+ try {
84
+ const { payload: decoded } = await jwtVerify(rawToken, secretKey);
85
+ const clientIdFromToken = typeof decoded.cid === "string"
86
+ ? decoded.cid
87
+ : typeof decoded.client_id === "string"
88
+ ? decoded.client_id
89
+ : undefined;
90
+ if (!clientIdFromToken) {
91
+ logger.warning("Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
92
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Invalid token, missing client identifier.");
93
+ }
94
+ let scopesFromToken = [];
95
+ if (Array.isArray(decoded.scp) &&
96
+ decoded.scp.every((s) => typeof s === "string")) {
97
+ scopesFromToken = decoded.scp;
98
+ }
99
+ else if (typeof decoded.scope === "string" &&
100
+ decoded.scope.trim() !== "") {
101
+ scopesFromToken = decoded.scope.split(" ").filter((s) => s);
102
+ if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") {
103
+ scopesFromToken = [decoded.scope.trim()];
104
+ }
105
+ }
106
+ if (scopesFromToken.length === 0) {
107
+ logger.warning("Authentication failed: Token resulted in an empty scope array, and scopes are required.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
108
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token must contain valid, non-empty scopes.");
109
+ }
110
+ reqWithAuth.auth = {
111
+ token: rawToken,
112
+ clientId: clientIdFromToken,
113
+ scopes: scopesFromToken,
114
+ };
115
+ const subClaimForLogging = typeof decoded.sub === "string" ? decoded.sub : undefined;
116
+ const authInfo = reqWithAuth.auth;
117
+ logger.debug("JWT verified successfully. AuthInfo attached to request.", {
118
+ ...context,
119
+ mcpSessionIdContext: subClaimForLogging,
120
+ clientId: authInfo.clientId,
121
+ scopes: authInfo.scopes,
122
+ });
123
+ await authContext.run({ authInfo }, next);
124
+ }
125
+ catch (error) {
126
+ let errorMessage = "Invalid token.";
127
+ let errorCode = BaseErrorCode.UNAUTHORIZED;
128
+ if (error instanceof Error && error.name === "JWTExpired") {
129
+ errorMessage = "Token expired.";
130
+ logger.warning("Authentication failed: Token expired.", {
131
+ ...context,
132
+ errorName: error.name,
133
+ });
134
+ }
135
+ else if (error instanceof Error) {
136
+ errorMessage = `Invalid token: ${error.message}`;
137
+ logger.warning(`Authentication failed: ${errorMessage}`, {
138
+ ...context,
139
+ errorName: error.name,
140
+ });
141
+ }
142
+ else {
143
+ errorMessage = "Unknown verification error.";
144
+ errorCode = BaseErrorCode.INTERNAL_ERROR;
145
+ logger.error("Authentication failed: Unexpected non-error exception during token verification.", { ...context, error });
146
+ }
147
+ throw new McpError(errorCode, errorMessage);
148
+ }
149
+ }
@@ -0,0 +1,127 @@
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/auth/strategies/oauth/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 "../../core/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.", {
47
+ error: error,
48
+ context: requestContextService.createRequestContext({
49
+ operation: "oauthMiddlewareSetup",
50
+ }),
51
+ });
52
+ // Prevent server from starting if JWKS setup fails in oauth mode
53
+ process.exit(1);
54
+ }
55
+ }
56
+ /**
57
+ * Hono middleware for verifying OAuth 2.1 JWT Bearer tokens.
58
+ * It validates the token and uses AsyncLocalStorage to pass auth info.
59
+ * @param c - The Hono context object.
60
+ * @param next - The function to call to proceed to the next middleware.
61
+ */
62
+ export async function oauthMiddleware(c, next) {
63
+ // If OAuth is not the configured auth mode, skip this middleware.
64
+ if (config.mcpAuthMode !== "oauth") {
65
+ return await next();
66
+ }
67
+ const context = requestContextService.createRequestContext({
68
+ operation: "oauthMiddleware",
69
+ httpMethod: c.req.method,
70
+ httpPath: c.req.path,
71
+ });
72
+ if (!jwks) {
73
+ // This should not happen if startup validation is correct, but it's a safeguard.
74
+ // This should not happen if startup validation is correct, but it's a safeguard.
75
+ throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OAuth middleware is active, but JWKS client is not initialized.", context);
76
+ }
77
+ const authHeader = c.req.header("Authorization");
78
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
79
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Missing or invalid token format.");
80
+ }
81
+ const token = authHeader.substring(7);
82
+ try {
83
+ const { payload } = await jwtVerify(token, jwks, {
84
+ issuer: config.oauthIssuerUrl,
85
+ audience: config.oauthAudience,
86
+ });
87
+ // The 'scope' claim is typically a space-delimited string in OAuth 2.1.
88
+ const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
89
+ if (scopes.length === 0) {
90
+ logger.warning("Authentication failed: Token contains no scopes, but scopes are required.", { ...context, jwtPayloadKeys: Object.keys(payload) });
91
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token must contain valid, non-empty scopes.");
92
+ }
93
+ const clientId = typeof payload.client_id === "string" ? payload.client_id : undefined;
94
+ if (!clientId) {
95
+ logger.warning("Authentication failed: OAuth token 'client_id' claim is missing or not a string.", { ...context, jwtPayloadKeys: Object.keys(payload) });
96
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Invalid token, missing client identifier.");
97
+ }
98
+ const authInfo = {
99
+ token,
100
+ clientId,
101
+ scopes,
102
+ subject: typeof payload.sub === "string" ? payload.sub : undefined,
103
+ };
104
+ // Attach to the raw request for potential legacy compatibility and
105
+ // store in AsyncLocalStorage for modern, safe access in handlers.
106
+ c.env.incoming.auth = authInfo;
107
+ await authContext.run({ authInfo }, next);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof Error && error.name === "JWTExpired") {
111
+ logger.warning("Authentication failed: OAuth token expired.", context);
112
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token expired.");
113
+ }
114
+ const handledError = ErrorHandler.handleError(error, {
115
+ operation: "oauthMiddleware",
116
+ context,
117
+ rethrow: false, // We will throw a new McpError below
118
+ });
119
+ // Ensure we always throw an McpError for consistency
120
+ if (handledError instanceof McpError) {
121
+ throw handledError;
122
+ }
123
+ else {
124
+ throw new McpError(BaseErrorCode.UNAUTHORIZED, `Unauthorized: ${handledError.message || "Invalid token"}`, { originalError: handledError.name });
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @fileoverview Centralized error handler for the Hono HTTP transport.
3
+ * This middleware intercepts errors that occur during request processing,
4
+ * standardizes them using the application's ErrorHandler utility, and
5
+ * formats them into a consistent JSON-RPC error response.
6
+ * @module src/mcp-server/transports/httpErrorHandler
7
+ */
8
+ import { BaseErrorCode, McpError } from "../../types-global/errors.js";
9
+ import { ErrorHandler, requestContextService } from "../../utils/index.js";
10
+ /**
11
+ * A centralized error handling middleware for Hono.
12
+ * This function is registered with `app.onError()` and will catch any errors
13
+ * thrown from preceding middleware or route handlers.
14
+ *
15
+ * @param err - The error that was thrown.
16
+ * @param c - The Hono context object for the request.
17
+ * @returns A Response object containing the formatted JSON-RPC error.
18
+ */
19
+ export const httpErrorHandler = async (err, c) => {
20
+ const context = requestContextService.createRequestContext({
21
+ operation: "httpErrorHandler",
22
+ path: c.req.path,
23
+ method: c.req.method,
24
+ });
25
+ const handledError = ErrorHandler.handleError(err, {
26
+ operation: "httpTransport",
27
+ context,
28
+ });
29
+ let status = 500;
30
+ if (handledError instanceof McpError) {
31
+ switch (handledError.code) {
32
+ case BaseErrorCode.NOT_FOUND:
33
+ status = 404;
34
+ break;
35
+ case BaseErrorCode.UNAUTHORIZED:
36
+ status = 401;
37
+ break;
38
+ case BaseErrorCode.FORBIDDEN:
39
+ status = 403;
40
+ break;
41
+ case BaseErrorCode.VALIDATION_ERROR:
42
+ status = 400;
43
+ break;
44
+ case BaseErrorCode.CONFLICT:
45
+ status = 409;
46
+ break;
47
+ case BaseErrorCode.RATE_LIMITED:
48
+ status = 429;
49
+ break;
50
+ default:
51
+ status = 500;
52
+ }
53
+ }
54
+ // Attempt to get the request ID from the body, but don't fail if it's not there or unreadable.
55
+ let requestId = null;
56
+ try {
57
+ const body = await c.req.json();
58
+ requestId = body?.id || null;
59
+ }
60
+ catch {
61
+ // Ignore parsing errors, requestId will remain null
62
+ }
63
+ const errorCode = handledError instanceof McpError ? handledError.code : -32603;
64
+ c.status(status);
65
+ return c.json({
66
+ jsonrpc: "2.0",
67
+ error: {
68
+ code: errorCode,
69
+ message: handledError.message,
70
+ },
71
+ id: requestId,
72
+ });
73
+ };