@cyanheads/git-mcp-server 2.0.11 → 2.0.12

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
@@ -1,8 +1,8 @@
1
1
  # Git MCP Server
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
- [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.11.2-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-2.0.11-blue.svg)](./CHANGELOG.md)
4
+ [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.12.0-green.svg)](https://modelcontextprotocol.io/)
5
+ [![Version](https://img.shields.io/badge/Version-2.0.12-blue.svg)](./CHANGELOG.md)
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-green.svg)](https://github.com/cyanheads/git-mcp-server/issues)
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/git-mcp-server?style=social)](https://github.com/cyanheads/git-mcp-server)
@@ -165,7 +165,7 @@ The Git MCP Server provides a suite of tools for interacting with Git repositori
165
165
  | `git_commit` | Commits staged changes. Supports author override, signing control. | `path?`, `message`, `author?`, `allowEmpty?`, `amend?`, `forceUnsignedOnFailure?` |
166
166
  | `git_diff` | Shows changes between commits, working tree, etc. | `path?`, `commit1?`, `commit2?`, `staged?`, `file?` |
167
167
  | `git_fetch` | Downloads objects and refs from other repositories. | `path?`, `remote?`, `prune?`, `tags?`, `all?` |
168
- | `git_init` | Initializes a new Git repository at the specified absolute path. | `path`, `initialBranch?`, `bare?`, `quiet?` |
168
+ | `git_init` | Initializes a new Git repository at the specified absolute path. Defaults to 'main' for initial branch. | `path`, `initialBranch?`, `bare?`, `quiet?` |
169
169
  | `git_log` | Shows commit logs. | `path?`, `maxCount?`, `author?`, `since?`, `until?`, `branchOrFile?` |
170
170
  | `git_merge` | Merges the specified branch into the current branch. | `path?`, `branch`, `commitMessage?`, `noFf?`, `squash?`, `abort?` |
171
171
  | `git_pull` | Fetches from and integrates with another repository or local branch. | `path?`, `remote?`, `branch?`, `rebase?`, `ffOnly?` |
@@ -173,19 +173,20 @@ The Git MCP Server provides a suite of tools for interacting with Git repositori
173
173
  | `git_rebase` | Reapplies commits on top of another base tip. | `path?`, `mode?`, `upstream?`, `branch?`, `interactive?`, `strategy?`, `strategyOption?`, `onto?` |
174
174
  | `git_remote` | Manages remote repositories (list, add, remove, show). | `path?`, `mode`, `name?`, `url?` |
175
175
  | `git_reset` | Resets current HEAD to a specified state. Supports soft, mixed, hard modes. **USE 'hard' WITH CAUTION**. | `path?`, `mode?`, `commit?` |
176
- | `git_set_working_dir` | Sets the default working directory for the current session. Requires absolute path. | `path`, `validateGitRepo?` |
176
+ | `git_set_working_dir` | Sets the default working directory. Can optionally initialize repo if not present. Requires absolute path. | `path`, `validateGitRepo?`, `initializeIfNotPresent?` |
177
177
  | `git_show` | Shows information about Git objects (commits, tags, etc.). | `path?`, `ref`, `filePath?` |
178
178
  | `git_stash` | Manages stashed changes (list, apply, pop, drop, save). | `path?`, `mode`, `stashRef?`, `message?` |
179
179
  | `git_status` | Gets repository status (branch, staged, modified, untracked files). | `path?` |
180
180
  | `git_tag` | Manages tags (list, create annotated/lightweight, delete). | `path?`, `mode`, `tagName?`, `message?`, `commitRef?`, `annotate?` |
181
+ | `git_worktree` | Manages Git worktrees (list, add, remove, move, prune). | `path?`, `mode`, `worktreePath?`, `commitish?`, `newBranch?`, `force?`, `detach?`, `newPath?`, `verbose?`, `dryRun?`, `expire?` |
181
182
 
182
183
  _Note: The `path` parameter for most tools defaults to the session's working directory if set via `git_set_working_dir`._
183
184
 
184
185
  ## Resources
185
186
 
186
- **MCP Resources are not implemented in this version (v2.0.8).**
187
+ **MCP Resources are not implemented in this version (v2.0.12).**
187
188
 
188
- This version focuses on the refactored Git tools implementation based on the latest `mcp-ts-template` and MCP SDK v1.11.0. Resource capabilities, previously available, have been temporarily removed during this major update.
189
+ This version focuses on the refactored Git tools implementation based on the latest `mcp-ts-template` and MCP SDK v1.12.0. Resource capabilities, previously available, have been temporarily removed during this major update.
189
190
 
190
191
  If you require MCP Resource access (e.g., for reading file content directly via the server), please use the stable **[v1.2.4 release](https://github.com/cyanheads/git-mcp-server/releases/tag/v1.2.4)**.
191
192
 
@@ -18,31 +18,32 @@ import { config, environment } from '../config/index.js';
18
18
  // Import core utilities: ErrorHandler, logger, requestContextService.
19
19
  import { ErrorHandler, logger, requestContextService } from '../utils/index.js'; // Added RequestContext
20
20
  // Import registration AND state initialization functions for ALL Git tools (alphabetized)
21
- import { registerGitAddTool, initializeGitAddStateAccessors } from './tools/gitAdd/index.js';
22
- import { registerGitBranchTool, initializeGitBranchStateAccessors } from './tools/gitBranch/index.js';
23
- import { registerGitCheckoutTool, initializeGitCheckoutStateAccessors } from './tools/gitCheckout/index.js';
24
- import { registerGitCherryPickTool, initializeGitCherryPickStateAccessors } from './tools/gitCherryPick/index.js';
25
- import { registerGitCleanTool, initializeGitCleanStateAccessors } from './tools/gitClean/index.js';
26
- import { registerGitClearWorkingDirTool, initializeGitClearWorkingDirStateAccessors } from './tools/gitClearWorkingDir/index.js';
21
+ import { initializeGitAddStateAccessors, registerGitAddTool } from './tools/gitAdd/index.js';
22
+ import { initializeGitBranchStateAccessors, registerGitBranchTool } from './tools/gitBranch/index.js';
23
+ import { initializeGitCheckoutStateAccessors, registerGitCheckoutTool } from './tools/gitCheckout/index.js';
24
+ import { initializeGitCherryPickStateAccessors, registerGitCherryPickTool } from './tools/gitCherryPick/index.js';
25
+ import { initializeGitCleanStateAccessors, registerGitCleanTool } from './tools/gitClean/index.js';
26
+ import { initializeGitClearWorkingDirStateAccessors, registerGitClearWorkingDirTool } from './tools/gitClearWorkingDir/index.js';
27
27
  import { registerGitCloneTool } from './tools/gitClone/index.js'; // No initializer needed/available
28
- import { registerGitCommitTool, initializeGitCommitStateAccessors } from './tools/gitCommit/index.js';
29
- import { registerGitDiffTool, initializeGitDiffStateAccessors } from './tools/gitDiff/index.js';
30
- import { registerGitFetchTool, initializeGitFetchStateAccessors } from './tools/gitFetch/index.js';
31
- import { registerGitInitTool, initializeGitInitStateAccessors } from './tools/gitInit/index.js';
32
- import { registerGitLogTool, initializeGitLogStateAccessors } from './tools/gitLog/index.js';
33
- import { registerGitMergeTool, initializeGitMergeStateAccessors } from './tools/gitMerge/index.js';
34
- import { registerGitPullTool, initializeGitPullStateAccessors } from './tools/gitPull/index.js';
35
- import { registerGitPushTool, initializeGitPushStateAccessors } from './tools/gitPush/index.js';
36
- import { registerGitRebaseTool, initializeGitRebaseStateAccessors } from './tools/gitRebase/index.js';
37
- import { registerGitRemoteTool, initializeGitRemoteStateAccessors } from './tools/gitRemote/index.js';
38
- import { registerGitResetTool, initializeGitResetStateAccessors } from './tools/gitReset/index.js';
39
- import { registerGitSetWorkingDirTool, initializeGitSetWorkingDirStateAccessors } from './tools/gitSetWorkingDir/index.js';
40
- import { registerGitShowTool, initializeGitShowStateAccessors } from './tools/gitShow/index.js';
41
- import { registerGitStashTool, initializeGitStashStateAccessors } from './tools/gitStash/index.js';
42
- import { registerGitStatusTool, initializeGitStatusStateAccessors } from './tools/gitStatus/index.js';
43
- import { registerGitTagTool, initializeGitTagStateAccessors } from './tools/gitTag/index.js';
28
+ import { initializeGitCommitStateAccessors, registerGitCommitTool } from './tools/gitCommit/index.js';
29
+ import { initializeGitDiffStateAccessors, registerGitDiffTool } from './tools/gitDiff/index.js';
30
+ import { initializeGitFetchStateAccessors, registerGitFetchTool } from './tools/gitFetch/index.js';
31
+ import { initializeGitInitStateAccessors, registerGitInitTool } from './tools/gitInit/index.js';
32
+ import { initializeGitLogStateAccessors, registerGitLogTool } from './tools/gitLog/index.js';
33
+ import { initializeGitMergeStateAccessors, registerGitMergeTool } from './tools/gitMerge/index.js';
34
+ import { initializeGitPullStateAccessors, registerGitPullTool } from './tools/gitPull/index.js';
35
+ import { initializeGitPushStateAccessors, registerGitPushTool } from './tools/gitPush/index.js';
36
+ import { initializeGitRebaseStateAccessors, registerGitRebaseTool } from './tools/gitRebase/index.js';
37
+ import { initializeGitRemoteStateAccessors, registerGitRemoteTool } from './tools/gitRemote/index.js';
38
+ import { initializeGitResetStateAccessors, registerGitResetTool } from './tools/gitReset/index.js';
39
+ import { initializeGitSetWorkingDirStateAccessors, registerGitSetWorkingDirTool } from './tools/gitSetWorkingDir/index.js';
40
+ import { initializeGitShowStateAccessors, registerGitShowTool } from './tools/gitShow/index.js';
41
+ import { initializeGitStashStateAccessors, registerGitStashTool } from './tools/gitStash/index.js';
42
+ import { initializeGitStatusStateAccessors, registerGitStatusTool } from './tools/gitStatus/index.js';
43
+ import { initializeGitTagStateAccessors, registerGitTagTool } from './tools/gitTag/index.js';
44
+ import { initializeGitWorktreeStateAccessors, registerGitWorktreeTool } from './tools/gitWorktree/index.js';
44
45
  // Import transport setup functions AND state accessors
45
- import { startHttpTransport, getHttpSessionWorkingDirectory, setHttpSessionWorkingDirectory } from './transports/httpTransport.js';
46
+ import { getHttpSessionWorkingDirectory, setHttpSessionWorkingDirectory, startHttpTransport } from './transports/httpTransport.js';
46
47
  import { connectStdioTransport, getStdioWorkingDirectory, setStdioWorkingDirectory } from './transports/stdioTransport.js';
47
48
  /**
48
49
  * Creates and configures a new instance of the McpServer.
@@ -157,6 +158,7 @@ async function createMcpServerInstance() {
157
158
  initializeGitStashStateAccessors(getWorkingDirectory, getSessionIdFromContext);
158
159
  initializeGitStatusStateAccessors(getWorkingDirectory, getSessionIdFromContext);
159
160
  initializeGitTagStateAccessors(getWorkingDirectory, getSessionIdFromContext);
161
+ initializeGitWorktreeStateAccessors(getWorkingDirectory, getSessionIdFromContext);
160
162
  logger.debug('State accessors initialized successfully.', context);
161
163
  }
162
164
  catch (initError) {
@@ -195,6 +197,7 @@ async function createMcpServerInstance() {
195
197
  await registerGitStashTool(server);
196
198
  await registerGitStatusTool(server);
197
199
  await registerGitTagTool(server);
200
+ await registerGitWorktreeTool(server);
198
201
  // Add calls to register other resources/tools here if needed in the future.
199
202
  logger.info('Git tools registered successfully', context);
200
203
  }
@@ -62,11 +62,9 @@ export async function gitInitLogic(input, context) {
62
62
  if (input.bare) {
63
63
  command += ' --bare';
64
64
  }
65
- if (input.initialBranch) {
66
- // Use -b for modern Git versions, older might need --initial-branch=
67
- // Sticking with -b as it's common now. Add quotes around branch name.
68
- command += ` -b "${input.initialBranch.replace(/"/g, '\\"')}"`;
69
- }
65
+ // Determine the initial branch name, defaulting to 'main' if not provided
66
+ const branchNameToUse = input.initialBranch || 'main';
67
+ command += ` -b "${branchNameToUse.replace(/"/g, '\\"')}"`;
70
68
  // Add the target directory path at the end
71
69
  command += ` "${targetPath}"`;
72
70
  logger.debug(`Executing command: ${command}`, { ...context, operation });
@@ -9,6 +9,7 @@ const execAsync = promisify(exec);
9
9
  export const GitSetWorkingDirInputSchema = z.object({
10
10
  path: z.string().min(1, "Path cannot be empty.").describe("The absolute path to set as the default working directory for the current session. Set this before using other git_* tools."),
11
11
  validateGitRepo: z.boolean().default(true).describe("Whether to validate that the path is a Git repository"),
12
+ initializeIfNotPresent: z.boolean().optional().default(false).describe("If true and the directory is not a Git repository, attempt to initialize it with 'git init'.")
12
13
  });
13
14
  /**
14
15
  * Logic for the git_set_working_dir tool.
@@ -51,25 +52,37 @@ export async function gitSetWorkingDirLogic(input, context // Assuming context p
51
52
  logger.error('Failed to stat directory', error, { ...context, operation, path: sanitizedPath });
52
53
  throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to access path: ${error.message}`, { context, operation });
53
54
  }
54
- // Optionally validate if it's a Git repository
55
- if (input.validateGitRepo) {
56
- logger.debug('Validating if path is a Git repository', { ...context, operation, path: sanitizedPath });
55
+ let isGitRepo = false;
56
+ let initializedRepo = false;
57
+ try {
58
+ const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: sanitizedPath });
59
+ if (stdout.trim() === 'true') {
60
+ isGitRepo = true;
61
+ logger.debug('Path is already a Git repository', { ...context, operation, path: sanitizedPath });
62
+ }
63
+ }
64
+ catch (error) {
65
+ logger.debug('Path is not a Git repository (rev-parse failed or returned non-true)', { ...context, operation, path: sanitizedPath, error: error.message });
66
+ isGitRepo = false;
67
+ }
68
+ if (!isGitRepo && input.initializeIfNotPresent) {
69
+ logger.info(`Path is not a Git repository. Attempting to initialize (initializeIfNotPresent=true) with initial branch 'main'.`, { ...context, operation, path: sanitizedPath });
57
70
  try {
58
- // A common way to check is using 'git rev-parse --is-inside-work-tree'
59
- // or checking for the existence of a .git directory/file.
60
- // Using rev-parse is generally more robust.
61
- const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: sanitizedPath });
62
- if (stdout.trim() !== 'true') {
63
- // This case should ideally not happen if rev-parse succeeds, but good to check.
64
- throw new Error('Not a Git repository (rev-parse returned non-true)');
65
- }
66
- logger.debug('Path validated as Git repository', { ...context, operation, path: sanitizedPath });
71
+ await execAsync('git init --initial-branch=main', { cwd: sanitizedPath });
72
+ initializedRepo = true;
73
+ isGitRepo = true; // Now it is a git repo
74
+ logger.info('Successfully initialized Git repository with initial branch "main".', { ...context, operation, path: sanitizedPath });
67
75
  }
68
- catch (error) {
69
- logger.warning('Path is not a valid Git repository', { ...context, operation, path: sanitizedPath, error: error.message });
70
- throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Path is not a valid Git repository: ${sanitizedPath}. Error: ${error.message}`, { context, operation });
76
+ catch (initError) {
77
+ logger.error('Failed to initialize Git repository', initError, { ...context, operation, path: sanitizedPath });
78
+ throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to initialize Git repository at ${sanitizedPath}: ${initError.message}`, { context, operation });
71
79
  }
72
80
  }
81
+ // After potential initialization, if validateGitRepo is true, it must now be a Git repo.
82
+ if (input.validateGitRepo && !isGitRepo) {
83
+ logger.warning('Path is not a valid Git repository and initialization was not performed or failed.', { ...context, operation, path: sanitizedPath });
84
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Path is not a valid Git repository: ${sanitizedPath}.`, { context, operation });
85
+ }
73
86
  // --- Update Session State ---
74
87
  // This part needs access to the session state mechanism defined in server.ts
75
88
  // We assume the context provides a way to set the working directory for the current session.
@@ -82,9 +95,23 @@ export async function gitSetWorkingDirLogic(input, context // Assuming context p
82
95
  // This indicates an internal logic error in how state is passed/managed.
83
96
  throw new McpError(BaseErrorCode.INTERNAL_ERROR, 'Failed to update session state.', { context, operation });
84
97
  }
98
+ let message = `Working directory set to: ${sanitizedPath}`;
99
+ if (initializedRepo) {
100
+ message += ' (New Git repository initialized).';
101
+ }
102
+ else if (isGitRepo && input.validateGitRepo) { // Only state "Existing" if validation was on and it passed
103
+ message += ' (Existing Git repository).';
104
+ }
105
+ else if (isGitRepo && !input.validateGitRepo) { // It is a git repo, but we weren't asked to validate it
106
+ message += ' (Is a Git repository, validation skipped).';
107
+ }
108
+ else if (!isGitRepo && !input.validateGitRepo && !input.initializeIfNotPresent) { // Not a git repo, validation off, no init request
109
+ message += ' (Not a Git repository, validation skipped, no initialization requested).';
110
+ }
85
111
  return {
86
112
  success: true,
87
- message: `Working directory set to: ${sanitizedPath}`,
113
+ message: message,
88
114
  path: sanitizedPath,
115
+ initialized: initializedRepo,
89
116
  };
90
117
  }
@@ -19,7 +19,7 @@ setWdFn, getSidFn) {
19
19
  logger.info('State accessors initialized for git_set_working_dir tool registration.');
20
20
  }
21
21
  const TOOL_NAME = 'git_set_working_dir';
22
- const TOOL_DESCRIPTION = "Sets the default working directory for the current session. Subsequent Git tool calls within this session can use '.' for the `path` parameter, which will resolve to this directory. Optionally validates if the path is a Git repository (`validateGitRepo: true`). Returns the result as a JSON object. IMPORTANT: The provided path must be absolute.";
22
+ const TOOL_DESCRIPTION = "Sets the default working directory for the current session. Subsequent Git tool calls within this session can use '.' for the `path` parameter, which will resolve to this directory. Optionally validates if the path is a Git repository (`validateGitRepo: true`). Can optionally initialize a Git repository with 'git init' if it's not already one and `initializeIfNotPresent: true` is set. Returns the result as a JSON object. IMPORTANT: The provided path must be absolute.";
23
23
  /**
24
24
  * Registers the git_set_working_dir tool with the MCP server.
25
25
  *
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @fileoverview Barrel file for the git_worktree tool.
3
+ * Exports the registration function and state accessor initialization function.
4
+ */
5
+ export { registerGitWorktreeTool, initializeGitWorktreeStateAccessors } from './registration.js';
6
+ // Export types if needed elsewhere, e.g.:
7
+ // export type { GitWorktreeInput, GitWorktreeResult } from './logic.js';
@@ -0,0 +1,239 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { z } from 'zod';
4
+ import { logger, sanitization } from '../../../utils/index.js';
5
+ import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
6
+ const execAsync = promisify(exec);
7
+ // Define the BASE input schema for the git_worktree tool using Zod
8
+ export const GitWorktreeBaseSchema = z.object({
9
+ path: z.string().min(1).optional().default('.').describe("Path to the local Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."),
10
+ mode: z.enum(['list', 'add', 'remove', 'move', 'prune']).describe("The worktree operation to perform: 'list', 'add', 'remove', 'move', 'prune'."),
11
+ // Common optional path for operations
12
+ worktreePath: z.string().min(1).optional().describe("Path of the worktree. Required for 'add', 'remove', 'move' modes."),
13
+ // 'add' mode specific
14
+ commitish: z.string().min(1).optional().describe("Branch or commit to checkout in the new worktree. Used only in 'add' mode. Defaults to HEAD."),
15
+ newBranch: z.string().min(1).optional().describe("Create a new branch in the worktree. Used only in 'add' mode."),
16
+ force: z.boolean().default(false).describe("Force the operation (e.g., for 'add' if branch exists, or 'remove' if uncommitted changes)."),
17
+ detach: z.boolean().default(false).describe("Detach HEAD in the new worktree. Used only in 'add' mode."),
18
+ // 'move' mode specific
19
+ newPath: z.string().min(1).optional().describe("The new path for the worktree. Required for 'move' mode."),
20
+ // 'prune' mode specific
21
+ verbose: z.boolean().default(false).describe("Provide more detailed output. Used in 'list' and 'prune' modes."),
22
+ dryRun: z.boolean().default(false).describe("Show what would be done without actually doing it. Used in 'prune' mode."),
23
+ expire: z.string().min(1).optional().describe("Prune entries older than this time (e.g., '1.month.ago'). Used in 'prune' mode."),
24
+ });
25
+ // Apply refinements and export the FINAL schema
26
+ export const GitWorktreeInputSchema = GitWorktreeBaseSchema.refine(data => !(data.mode === 'add' && !data.worktreePath), {
27
+ message: "A 'worktreePath' is required for 'add' mode.", path: ["worktreePath"],
28
+ }).refine(data => !(data.mode === 'remove' && !data.worktreePath), {
29
+ message: "A 'worktreePath' is required for 'remove' mode.", path: ["worktreePath"],
30
+ }).refine(data => !(data.mode === 'move' && (!data.worktreePath || !data.newPath)), {
31
+ message: "Both 'worktreePath' (old path) and 'newPath' are required for 'move' mode.", path: ["worktreePath", "newPath"],
32
+ });
33
+ /**
34
+ * Parses the output of `git worktree list --porcelain`.
35
+ */
36
+ function parsePorcelainWorktreeList(stdout) {
37
+ const worktrees = [];
38
+ const entries = stdout.trim().split('\n\n'); // Entries are separated by double newlines
39
+ for (const entry of entries) {
40
+ const lines = entry.trim().split('\n');
41
+ let path = '';
42
+ let head = '';
43
+ let branch;
44
+ let isBare = false;
45
+ let isLocked = false;
46
+ let isPrunable = false;
47
+ let prunableReason;
48
+ for (const line of lines) {
49
+ if (line.startsWith('worktree ')) {
50
+ path = line.substring('worktree '.length);
51
+ }
52
+ else if (line.startsWith('HEAD ')) {
53
+ head = line.substring('HEAD '.length);
54
+ }
55
+ else if (line.startsWith('branch ')) {
56
+ branch = line.substring('branch '.length);
57
+ }
58
+ else if (line.startsWith('bare')) {
59
+ isBare = true;
60
+ }
61
+ else if (line.startsWith('locked')) {
62
+ isLocked = true;
63
+ const reasonMatch = line.match(/locked(?: (.+))?/);
64
+ if (reasonMatch && reasonMatch[1]) {
65
+ prunableReason = reasonMatch[1]; // Using prunableReason for lock reason too
66
+ }
67
+ }
68
+ else if (line.startsWith('prunable')) {
69
+ isPrunable = true;
70
+ const reasonMatch = line.match(/prunable(?: (.+))?/);
71
+ if (reasonMatch && reasonMatch[1]) {
72
+ prunableReason = reasonMatch[1];
73
+ }
74
+ }
75
+ }
76
+ if (path) { // Only add if a path was found
77
+ worktrees.push({ path, head, branch, isBare, isLocked, isPrunable, prunableReason });
78
+ }
79
+ }
80
+ return worktrees;
81
+ }
82
+ /**
83
+ * Executes git worktree commands.
84
+ */
85
+ export async function gitWorktreeLogic(input, context) {
86
+ const operation = `gitWorktreeLogic:${input.mode}`;
87
+ logger.debug(`Executing ${operation}`, { ...context, input });
88
+ let targetPath;
89
+ try {
90
+ const workingDir = context.getWorkingDirectory();
91
+ targetPath = (input.path && input.path !== '.')
92
+ ? input.path
93
+ : workingDir ?? '.';
94
+ if (targetPath === '.' && !workingDir) {
95
+ logger.warning("Executing git worktree in server's CWD as no path provided and no session WD set.", { ...context, operation });
96
+ targetPath = process.cwd();
97
+ }
98
+ else if (targetPath === '.' && workingDir) {
99
+ targetPath = workingDir;
100
+ logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
101
+ }
102
+ else {
103
+ logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
104
+ }
105
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
106
+ }
107
+ catch (error) {
108
+ logger.error('Path resolution or sanitization failed', { ...context, operation, error });
109
+ if (error instanceof McpError)
110
+ throw error;
111
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
112
+ }
113
+ try {
114
+ let command = `git -C "${targetPath}" worktree `;
115
+ let result;
116
+ switch (input.mode) {
117
+ case 'list':
118
+ command += 'list';
119
+ if (input.verbose)
120
+ command += ' --porcelain'; // Use porcelain for structured output
121
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
122
+ const { stdout: listStdout } = await execAsync(command);
123
+ if (input.verbose) {
124
+ const worktrees = parsePorcelainWorktreeList(listStdout);
125
+ result = { success: true, mode: 'list', worktrees };
126
+ }
127
+ else {
128
+ // Simple list output parsing (less structured)
129
+ const worktrees = listStdout.trim().split('\n').map(line => {
130
+ const parts = line.split(/\s+/);
131
+ return {
132
+ path: parts[0],
133
+ head: parts[1],
134
+ branch: parts[2]?.replace(/[\[\]]/g, ''), // Remove brackets from branch name
135
+ isBare: false, // Cannot determine from simple list
136
+ isLocked: false, // Cannot determine
137
+ isPrunable: false // Cannot determine
138
+ };
139
+ });
140
+ result = { success: true, mode: 'list', worktrees };
141
+ }
142
+ break;
143
+ case 'add':
144
+ // worktreePath is guaranteed by refine
145
+ const sanitizedWorktreePathAdd = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
146
+ command += `add `;
147
+ if (input.force)
148
+ command += '--force ';
149
+ if (input.detach)
150
+ command += '--detach ';
151
+ if (input.newBranch)
152
+ command += `-b "${input.newBranch}" `;
153
+ command += `"${sanitizedWorktreePathAdd}"`;
154
+ if (input.commitish)
155
+ command += ` "${input.commitish}"`;
156
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
157
+ await execAsync(command);
158
+ // To get the HEAD of the new worktree, we might need another command or parse output if available
159
+ // For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD`
160
+ result = {
161
+ success: true,
162
+ mode: 'add',
163
+ worktreePath: sanitizedWorktreePathAdd,
164
+ branch: input.newBranch,
165
+ head: 'HEAD', // Placeholder, actual SHA would require another call
166
+ message: `Worktree '${sanitizedWorktreePathAdd}' added successfully.`
167
+ };
168
+ break;
169
+ case 'remove':
170
+ // worktreePath is guaranteed by refine
171
+ const sanitizedWorktreePathRemove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
172
+ command += `remove `;
173
+ if (input.force)
174
+ command += '--force ';
175
+ command += `"${sanitizedWorktreePathRemove}"`;
176
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
177
+ const { stdout: removeStdout } = await execAsync(command);
178
+ result = { success: true, mode: 'remove', worktreePath: sanitizedWorktreePathRemove, message: removeStdout.trim() || `Worktree '${sanitizedWorktreePathRemove}' removed successfully.` };
179
+ break;
180
+ case 'move':
181
+ // worktreePath and newPath are guaranteed by refine
182
+ const sanitizedOldPathMove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
183
+ const sanitizedNewPathMove = sanitization.sanitizePath(input.newPath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
184
+ command += `move "${sanitizedOldPathMove}" "${sanitizedNewPathMove}"`;
185
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
186
+ await execAsync(command);
187
+ result = { success: true, mode: 'move', oldPath: sanitizedOldPathMove, newPath: sanitizedNewPathMove, message: `Worktree moved from '${sanitizedOldPathMove}' to '${sanitizedNewPathMove}' successfully.` };
188
+ break;
189
+ case 'prune':
190
+ command += 'prune ';
191
+ if (input.dryRun)
192
+ command += '--dry-run ';
193
+ if (input.verbose)
194
+ command += '--verbose ';
195
+ if (input.expire)
196
+ command += `--expire "${input.expire}" `;
197
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
198
+ const { stdout: pruneStdout, stderr: pruneStderr } = await execAsync(command);
199
+ // Prune often outputs to stderr even on success for verbose/dry-run
200
+ const pruneMessage = (pruneStdout.trim() || pruneStderr.trim()) || 'Worktree prune operation completed.';
201
+ result = { success: true, mode: 'prune', message: pruneMessage };
202
+ if (input.verbose && pruneStdout.trim()) {
203
+ // Attempt to parse verbose output if needed, for now just return raw message
204
+ // result.prunedItems = ...
205
+ }
206
+ break;
207
+ default:
208
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
209
+ }
210
+ logger.info(`${operation} executed successfully`, { ...context, path: targetPath });
211
+ return result;
212
+ }
213
+ catch (error) {
214
+ const errorMessage = error.stderr || error.stdout || (error.message || '');
215
+ logger.error(`Failed to execute git worktree command`, { ...context, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
216
+ if (errorMessage.toLowerCase().includes('not a git repository')) {
217
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
218
+ }
219
+ // Add more specific error handling based on `git worktree` messages
220
+ if (input.mode === 'add' && errorMessage.includes('already exists')) {
221
+ return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' already exists or is a worktree.`, error: errorMessage };
222
+ }
223
+ if (input.mode === 'add' && errorMessage.includes('is a submodule')) {
224
+ return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' is a submodule.`, error: errorMessage };
225
+ }
226
+ if (input.mode === 'remove' && errorMessage.includes('cannot remove the current worktree')) {
227
+ return { success: false, mode: 'remove', message: `Failed to remove worktree: Cannot remove the current worktree.`, error: errorMessage };
228
+ }
229
+ if (input.mode === 'remove' && errorMessage.includes('has unclean changes')) {
230
+ return { success: false, mode: 'remove', message: `Failed to remove worktree: '${input.worktreePath}' has uncommitted changes. Use force=true to remove.`, error: errorMessage };
231
+ }
232
+ return {
233
+ success: false,
234
+ mode: input.mode,
235
+ message: `Git worktree ${input.mode} failed for path: ${targetPath}.`,
236
+ error: errorMessage
237
+ };
238
+ }
239
+ }
@@ -0,0 +1,67 @@
1
+ import { logger, ErrorHandler, requestContextService } from '../../../utils/index.js';
2
+ import { BaseErrorCode } from '../../../types-global/errors.js';
3
+ import { GitWorktreeBaseSchema, gitWorktreeLogic } from './logic.js';
4
+ let _getWorkingDirectory;
5
+ let _getSessionId;
6
+ /**
7
+ * Initializes the state accessors needed by the git_worktree tool registration.
8
+ * @param getWdFn - Function to get the working directory for a session.
9
+ * @param getSidFn - Function to get the session ID from context.
10
+ */
11
+ export function initializeGitWorktreeStateAccessors(getWdFn, getSidFn) {
12
+ _getWorkingDirectory = getWdFn;
13
+ _getSessionId = getSidFn;
14
+ logger.info('State accessors initialized for git_worktree tool registration.');
15
+ }
16
+ const TOOL_NAME = 'git_worktree';
17
+ const TOOL_DESCRIPTION = 'Manages Git worktrees. Supports listing, adding, removing, moving, and pruning worktrees. Returns results as a JSON object.';
18
+ /**
19
+ * Registers the git_worktree tool with the MCP server.
20
+ *
21
+ * @param {McpServer} server - The McpServer instance to register the tool with.
22
+ * @returns {Promise<void>}
23
+ * @throws {Error} If registration fails or state accessors are not initialized.
24
+ */
25
+ export const registerGitWorktreeTool = async (server) => {
26
+ if (!_getWorkingDirectory || !_getSessionId) {
27
+ throw new Error('State accessors for git_worktree must be initialized before registration.');
28
+ }
29
+ const operation = 'registerGitWorktreeTool';
30
+ const context = requestContextService.createRequestContext({ operation });
31
+ await ErrorHandler.tryCatch(async () => {
32
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitWorktreeBaseSchema.shape, async (validatedArgs, callContext) => {
33
+ const toolInput = validatedArgs; // Cast for use
34
+ const toolOperation = `tool:${TOOL_NAME}:${toolInput.mode}`;
35
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
36
+ const sessionId = _getSessionId(requestContext);
37
+ const getWorkingDirectoryForSession = () => _getWorkingDirectory(sessionId);
38
+ const logicContext = {
39
+ ...requestContext,
40
+ sessionId: sessionId,
41
+ getWorkingDirectory: getWorkingDirectoryForSession,
42
+ };
43
+ logger.info(`Executing tool: ${TOOL_NAME} (mode: ${toolInput.mode})`, logicContext);
44
+ return await ErrorHandler.tryCatch(async () => {
45
+ const worktreeResult = await gitWorktreeLogic(toolInput, logicContext);
46
+ const resultContent = {
47
+ type: 'text',
48
+ text: JSON.stringify(worktreeResult, null, 2), // Pretty-print JSON
49
+ contentType: 'application/json',
50
+ };
51
+ if (worktreeResult.success) {
52
+ logger.info(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) executed successfully, returning JSON`, logicContext);
53
+ }
54
+ else {
55
+ logger.warning(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) failed: ${worktreeResult.message}`, { ...logicContext, errorDetails: worktreeResult.error });
56
+ }
57
+ return { content: [resultContent] };
58
+ }, {
59
+ operation: toolOperation,
60
+ context: logicContext,
61
+ input: validatedArgs,
62
+ errorCode: BaseErrorCode.INTERNAL_ERROR,
63
+ });
64
+ });
65
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
66
+ }, { operation, context, critical: true });
67
+ };
@@ -1,145 +1,167 @@
1
1
  /**
2
- * MCP Authentication Middleware: Bearer Token Validation (JWT).
2
+ * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT).
3
3
  *
4
4
  * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
5
5
  * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
6
6
  * It verifies the token's signature and expiration using the secret key defined
7
- * in the configuration (MCP_AUTH_SECRET_KEY).
7
+ * in the configuration (`config.mcpAuthSecretKey`).
8
8
  *
9
- * If the token is valid, the decoded payload is attached to `req.auth` for potential
10
- * use in downstream authorization logic (e.g., checking scopes or permissions).
9
+ * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type
10
+ * (expected to contain `token`, `clientId`, and `scopes`) is attached to `req.auth`.
11
11
  * If the token is missing, invalid, or expired, it sends an HTTP 401 Unauthorized response.
12
12
  *
13
- * --- Scope and Relation to MCP Authorization Spec (2025-03-26) ---
14
- * - This middleware handles the *validation* of an already obtained Bearer token,
15
- * as required by Section 2.6 of the MCP Auth Spec.
16
- * - It does *NOT* implement the full OAuth 2.1 authorization flows (e.g., Authorization
17
- * Code Grant with PKCE), token endpoints (/token), authorization endpoints (/authorize),
18
- * metadata discovery (/.well-known/oauth-authorization-server), or dynamic client
19
- * registration (/register) described in the specification. It assumes the client
20
- * obtained the JWT through an external process compliant with the spec or another
21
- * agreed-upon mechanism.
22
- * - It correctly returns HTTP 401 errors for invalid/missing tokens as per Section 2.8.
23
- *
24
- * --- Implementation Details & Requirements ---
25
- * - Requires the 'jsonwebtoken' package (`npm install jsonwebtoken @types/jsonwebtoken`).
26
- * - The `MCP_AUTH_SECRET_KEY` environment variable MUST be set to a strong, secret value
27
- * in production. The middleware includes a startup check for this.
28
- * - In non-production environments, if the secret key is missing, authentication checks
29
- * are bypassed for development convenience (a warning is logged). THIS IS INSECURE FOR PRODUCTION.
30
- * - The structure of the JWT payload (e.g., containing user ID, scopes) depends on the
31
- * token issuer and is not dictated by this middleware itself, but the payload is made
32
- * available on `req.auth`.
33
- *
34
13
  * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
14
+ * @module src/mcp-server/transports/authentication/authMiddleware
35
15
  */
36
- import jwt from 'jsonwebtoken';
37
- // Import config, environment constants, and logger
38
- import { config, environment } from '../../../config/index.js';
39
- import { logger } from '../../../utils/index.js';
40
- // --- Startup Validation ---
41
- // Validate secret key presence on module load (fail fast principle).
42
- // This prevents the server starting insecurely in production without the key.
43
- if (environment === 'production' && !config.security.mcpAuthSecretKey) {
44
- logger.fatal('CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.');
45
- // Throwing an error here will typically stop the Node.js process.
46
- throw new Error('MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.');
16
+ import jwt from "jsonwebtoken";
17
+ import { config, environment } from "../../../config/index.js";
18
+ import { logger, requestContextService } from "../../../utils/index.js";
19
+ // Startup Validation: Validate secret key presence on module load.
20
+ if (environment === "production" && !config.security.mcpAuthSecretKey) {
21
+ logger.fatal("CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.");
22
+ throw new Error("MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.");
47
23
  }
48
24
  else if (!config.security.mcpAuthSecretKey) {
49
- // Log a clear warning if running without a key in non-production environments.
50
- logger.warning('MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.');
25
+ logger.warning("MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.");
51
26
  }
52
27
  /**
53
- * Express middleware function for verifying JWT Bearer token authentication.
54
- * Checks the `Authorization` header, verifies the token, and attaches the payload to `req.auth`.
55
- *
56
- * @param {Request} req - Express request object.
57
- * @param {Response} res - Express response object.
58
- * @param {NextFunction} next - Express next middleware function.
28
+ * Express middleware for verifying JWT Bearer token authentication.
59
29
  */
60
30
  export function mcpAuthMiddleware(req, res, next) {
61
- // Establish context for logging associated with this middleware execution.
62
- const context = { operation: 'mcpAuthMiddleware', method: req.method, path: req.path };
63
- logger.debug('Running MCP Authentication Middleware (Bearer Token Validation)...', context);
64
- // --- Development Mode Bypass ---
65
- // If the secret key is missing (and not in production), bypass authentication.
31
+ const context = requestContextService.createRequestContext({
32
+ operation: "mcpAuthMiddleware",
33
+ method: req.method,
34
+ path: req.path,
35
+ });
36
+ logger.debug("Running MCP Authentication Middleware (Bearer Token Validation)...", context);
37
+ // Development Mode Bypass
66
38
  if (!config.security.mcpAuthSecretKey) {
67
- // Double-check environment for safety, although the startup check should prevent this in prod.
68
- if (environment !== 'production') {
69
- logger.warning('Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).', context);
70
- // Attach a dummy auth object to indicate bypass for potential downstream checks.
71
- req.auth = { devMode: true, warning: 'Auth bypassed due to missing secret key' };
72
- return next(); // Proceed without authentication.
39
+ if (environment !== "production") {
40
+ logger.warning("Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).", context);
41
+ // Populate req.auth strictly according to SDK's AuthInfo
42
+ req.auth = {
43
+ token: "dev-mode-placeholder-token",
44
+ clientId: "dev-client-id",
45
+ scopes: ["dev-scope"],
46
+ };
47
+ // Log dev mode details separately, not attaching to req.auth if not part of AuthInfo
48
+ logger.debug("Dev mode auth object created.", {
49
+ ...context,
50
+ authDetails: req.auth,
51
+ });
52
+ return next();
73
53
  }
74
54
  else {
75
- // Defensive coding: Should be caught by startup check, but handle anyway.
76
- logger.error('FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.', context);
77
- // Send a server error response as this indicates a critical configuration issue.
78
- res.status(500).json({ error: 'Server configuration error: Authentication key missing.' });
79
- return; // Halt processing.
55
+ logger.error("FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.", context);
56
+ res.status(500).json({
57
+ error: "Server configuration error: Authentication key missing.",
58
+ });
59
+ return;
80
60
  }
81
61
  }
82
- // --- Standard JWT Bearer Token Verification ---
83
62
  const authHeader = req.headers.authorization;
84
- logger.debug(`Authorization header present: ${!!authHeader}`, context);
85
- // Check for the presence and correct format ('Bearer <token>') of the Authorization header.
86
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
87
- logger.warning('Authentication failed: Missing or malformed Authorization header (Bearer scheme required).', context);
88
- // Respond with 401 Unauthorized as per RFC 6750 (Bearer Token Usage).
89
- res.status(401).json({ error: 'Unauthorized: Missing or invalid authentication token format.' });
90
- return; // Halt processing.
63
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
64
+ logger.warning("Authentication failed: Missing or malformed Authorization header (Bearer scheme required).", context);
65
+ res.status(401).json({
66
+ error: "Unauthorized: Missing or invalid authentication token format.",
67
+ });
68
+ return;
91
69
  }
92
- // Extract the token part from the "Bearer <token>" string.
93
- const token = authHeader.split(' ')[1];
94
- // Avoid logging the token itself for security reasons.
95
- logger.debug('Extracted token from Bearer header.', context);
96
- // Check if a token was actually present after the split.
97
- if (!token) {
98
- logger.warning('Authentication failed: Token missing after Bearer split (Malformed header).', context);
99
- res.status(401).json({ error: 'Unauthorized: Malformed authentication token.' });
100
- return; // Halt processing.
70
+ const tokenParts = authHeader.split(" ");
71
+ if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) {
72
+ logger.warning("Authentication failed: Malformed Bearer token.", context);
73
+ res
74
+ .status(401)
75
+ .json({ error: "Unauthorized: Malformed authentication token." });
76
+ return;
101
77
  }
78
+ const rawToken = tokenParts[1];
102
79
  try {
103
- // Verify the token's signature and expiration using the configured secret key.
104
- // `jwt.verify` throws errors for invalid signature, expiration, etc.
105
- const decoded = jwt.verify(token, config.security.mcpAuthSecretKey);
106
- // Avoid logging the decoded payload directly unless necessary for specific debugging,
107
- // as it might contain sensitive information.
108
- logger.debug('JWT verified successfully.', { ...context });
109
- // Attach the decoded payload (which can be an object or string based on JWT content)
110
- // to the request object (`req.auth`) for use in subsequent middleware or route handlers
111
- // (e.g., for fine-grained authorization checks based on payload claims like user ID or scopes).
112
- req.auth = decoded;
113
- // Authentication successful, proceed to the next middleware or the main route handler.
80
+ const decoded = jwt.verify(rawToken, config.security.mcpAuthSecretKey);
81
+ if (typeof decoded === "string") {
82
+ logger.warning("Authentication failed: JWT decoded to a string, expected an object payload.", context);
83
+ res
84
+ .status(401)
85
+ .json({ error: "Unauthorized: Invalid token payload format." });
86
+ return;
87
+ }
88
+ // Extract and validate fields for SDK's AuthInfo
89
+ const clientIdFromToken = typeof decoded.cid === "string"
90
+ ? decoded.cid
91
+ : typeof decoded.client_id === "string"
92
+ ? decoded.client_id
93
+ : undefined;
94
+ if (!clientIdFromToken) {
95
+ logger.warning("Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
96
+ res.status(401).json({
97
+ error: "Unauthorized: Invalid token, missing client identifier.",
98
+ });
99
+ return;
100
+ }
101
+ let scopesFromToken;
102
+ if (Array.isArray(decoded.scp) &&
103
+ decoded.scp.every((s) => typeof s === "string")) {
104
+ scopesFromToken = decoded.scp;
105
+ }
106
+ else if (typeof decoded.scope === "string" &&
107
+ decoded.scope.trim() !== "") {
108
+ scopesFromToken = decoded.scope.split(" ").filter((s) => s);
109
+ if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") {
110
+ // handles case " " -> [""]
111
+ scopesFromToken = [decoded.scope.trim()];
112
+ }
113
+ else if (scopesFromToken.length === 0 && decoded.scope.trim() === "") {
114
+ // If scope is an empty string, treat as no scopes rather than erroring, or use a default.
115
+ // Depending on strictness, could also error here. For now, allow empty array if scope was empty string.
116
+ logger.debug("JWT 'scope' claim was an empty string, resulting in empty scopes array.", context);
117
+ }
118
+ }
119
+ else {
120
+ // If scopes are strictly mandatory and not found or invalid format
121
+ logger.warning("Authentication failed: JWT 'scp' or 'scope' claim is missing, not an array of strings, or not a valid space-separated string. Assigning default empty array.", { ...context, jwtPayloadKeys: Object.keys(decoded) });
122
+ scopesFromToken = []; // Default to empty array if scopes are mandatory but not found/invalid
123
+ // Or, if truly mandatory and must be non-empty:
124
+ // res.status(401).json({ error: "Unauthorized: Invalid token, missing or invalid scopes." });
125
+ // return;
126
+ }
127
+ // Construct req.auth with only the properties defined in SDK's AuthInfo
128
+ // All other claims from 'decoded' are not part of req.auth for type safety.
129
+ req.auth = {
130
+ token: rawToken,
131
+ clientId: clientIdFromToken,
132
+ scopes: scopesFromToken,
133
+ };
134
+ // Log separately if other JWT claims like 'sub' (sessionId) are needed for app logic
135
+ const subClaimForLogging = typeof decoded.sub === "string" ? decoded.sub : undefined;
136
+ logger.debug("JWT verified successfully. AuthInfo attached to request.", {
137
+ ...context,
138
+ mcpSessionIdContext: subClaimForLogging,
139
+ clientId: req.auth.clientId,
140
+ scopes: req.auth.scopes,
141
+ });
114
142
  next();
115
143
  }
116
144
  catch (error) {
117
- // Handle errors thrown by `jwt.verify`.
118
- let errorMessage = 'Invalid token'; // Default error message.
145
+ let errorMessage = "Invalid token";
119
146
  if (error instanceof jwt.TokenExpiredError) {
120
- // Specific error for expired tokens.
121
- errorMessage = 'Token expired';
122
- // After instanceof check, 'error' is typed as TokenExpiredError
123
- logger.warning('Authentication failed: Token expired.', { ...context, expiredAt: error.expiredAt }); // Log specific details here
147
+ errorMessage = "Token expired";
148
+ logger.warning("Authentication failed: Token expired.", {
149
+ ...context,
150
+ expiredAt: error.expiredAt,
151
+ });
124
152
  }
125
153
  else if (error instanceof jwt.JsonWebTokenError) {
126
- // General JWT errors (e.g., invalid signature, malformed token).
127
- // After instanceof check, 'error' is typed as JsonWebTokenError
128
- errorMessage = `Invalid token: ${error.message}`; // Include specific JWT error message
129
- logger.warning(`Authentication failed: ${errorMessage}`, { ...context }); // Log specific details here
154
+ errorMessage = `Invalid token: ${error.message}`;
155
+ logger.warning(`Authentication failed: ${errorMessage}`, { ...context });
130
156
  }
131
157
  else if (error instanceof Error) {
132
- // Handle other standard JavaScript errors
133
158
  errorMessage = `Verification error: ${error.message}`;
134
- logger.error('Authentication failed: Unexpected error during token verification.', { ...context, error: error.message }); // Log specific details here
159
+ logger.error("Authentication failed: Unexpected error during token verification.", { ...context, error: error.message });
135
160
  }
136
161
  else {
137
- // Handle non-Error exceptions
138
- errorMessage = 'Unknown verification error';
139
- logger.error('Authentication failed: Unexpected non-error exception during token verification.', { ...context, error });
162
+ errorMessage = "Unknown verification error";
163
+ logger.error("Authentication failed: Unexpected non-error exception during token verification.", { ...context, error });
140
164
  }
141
- // Respond with 401 Unauthorized for any token validation failure.
142
165
  res.status(401).json({ error: `Unauthorized: ${errorMessage}.` });
143
- // Do not call next() - halt processing for this request.
144
166
  }
145
167
  }
@@ -339,6 +339,13 @@ export async function startHttpTransport(createServerInstanceFn, context) {
339
339
  logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
340
340
  // Delegate the actual handling (parsing, routing, response/SSE generation) to the SDK transport instance.
341
341
  // The SDK transport handles returning 202 for notification/response-only POSTs internally.
342
+ // --- Type modification for req.auth compatibility ---
343
+ const tempReqPost = req; // Allow modification
344
+ if (tempReqPost.auth && (typeof tempReqPost.auth === 'string' || (typeof tempReqPost.auth === 'object' && 'devMode' in tempReqPost.auth))) {
345
+ logger.debug('Sanitizing req.auth for SDK compatibility (POST)', { ...basePostContext, sessionId: currentSessionId, originalAuthType: typeof tempReqPost.auth });
346
+ tempReqPost.auth = undefined;
347
+ }
348
+ // --- End modification ---
342
349
  await transport.handleRequest(req, res, req.body);
343
350
  logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
344
351
  }
@@ -390,6 +397,13 @@ export async function startHttpTransport(createServerInstanceFn, context) {
390
397
  // MCP Spec (GET): Client SHOULD include Last-Event-ID for resumption. Resumption handling depends on SDK transport.
391
398
  // MCP Spec (DELETE): Client SHOULD send DELETE to terminate. Server MAY respond 405 if not supported.
392
399
  // This implementation supports DELETE via the SDK transport's handleRequest.
400
+ // --- Type modification for req.auth compatibility ---
401
+ const tempReqSession = req; // Allow modification
402
+ if (tempReqSession.auth && (typeof tempReqSession.auth === 'string' || (typeof tempReqSession.auth === 'object' && 'devMode' in tempReqSession.auth))) {
403
+ logger.debug(`Sanitizing req.auth for SDK compatibility (${method})`, { ...baseSessionReqContext, sessionId, originalAuthType: typeof tempReqSession.auth });
404
+ tempReqSession.auth = undefined;
405
+ }
406
+ // --- End modification ---
393
407
  await transport.handleRequest(req, res);
394
408
  logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId });
395
409
  // Note: For DELETE, the transport's handleRequest should trigger the 'onclose' handler for cleanup.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@cyanheads/git-mcp-server",
3
- "version": "2.0.11",
4
- "description": "An MCP (Model Context Protocol) server providing tools to interact with Git repositories. Enables LLMs and AI agents to perform Git operations like clone, commit, push, pull, branch, diff, log, status, and more via the MCP standard.",
3
+ "version": "2.0.12",
4
+ "description": "An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management, and more, via the MCP standard. STDIO & HTTP.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Casey Hand @cyanheads",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/cyanheads/git-mcp-server"
10
+ "url": "git+https://github.com/cyanheads/git-mcp-server.git"
11
11
  },
12
12
  "homepage": "https://github.com/cyanheads/git-mcp-server#readme",
13
13
  "bugs": {
@@ -17,7 +17,7 @@
17
17
  "node": ">=18.0.0"
18
18
  },
19
19
  "bin": {
20
- "git-mcp-server": "./dist/index.js"
20
+ "git-mcp-server": "dist/index.js"
21
21
  },
22
22
  "files": [
23
23
  "dist"
@@ -37,18 +37,18 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@modelcontextprotocol/inspector": "^0.12.0",
41
- "@modelcontextprotocol/sdk": "^1.11.2",
40
+ "@modelcontextprotocol/inspector": "^0.13.0",
41
+ "@modelcontextprotocol/sdk": "^1.12.0",
42
42
  "@types/jsonwebtoken": "^9.0.9",
43
- "@types/node": "^22.15.18",
43
+ "@types/node": "^22.15.21",
44
44
  "@types/sanitize-html": "^2.16.0",
45
- "@types/validator": "^13.15.0",
46
- "chrono-node": "^2.8.0",
45
+ "@types/validator": "^13.15.1",
46
+ "chrono-node": "2.8.0",
47
47
  "dotenv": "^16.5.0",
48
48
  "express": "^5.1.0",
49
49
  "ignore": "^7.0.4",
50
50
  "jsonwebtoken": "^9.0.2",
51
- "openai": "^4.98.0",
51
+ "openai": "^4.103.0",
52
52
  "partial-json": "^0.1.7",
53
53
  "sanitize-html": "^2.17.0",
54
54
  "tiktoken": "^1.0.21",
@@ -58,32 +58,44 @@
58
58
  "winston": "^3.17.0",
59
59
  "winston-daily-rotate-file": "^5.0.0",
60
60
  "yargs": "^17.7.2",
61
- "zod": "^3.24.4"
61
+ "zod": "^3.25.28"
62
62
  },
63
63
  "keywords": [
64
64
  "typescript",
65
65
  "MCP",
66
66
  "model-context-protocol",
67
+ "mcp-server",
68
+ "llm-tools",
69
+ "git-tools",
67
70
  "LLM",
68
71
  "AI-integration",
69
72
  "server",
70
73
  "git",
71
74
  "version-control",
72
75
  "repository",
73
- "commit",
74
76
  "branch",
77
+ "cherry-pick",
78
+ "clone",
79
+ "commit",
80
+ "devops",
75
81
  "diff",
82
+ "fetch",
76
83
  "log",
77
- "status",
78
- "push",
84
+ "llm-tools",
85
+ "merge",
79
86
  "pull",
80
- "clone",
81
- "automation",
82
- "devops",
87
+ "push",
88
+ "rebase",
89
+ "remote",
90
+ "reset",
91
+ "stash",
92
+ "status",
93
+ "tag",
94
+ "worktree",
83
95
  "ai-agent",
84
- "llm-tools"
96
+ "automation"
85
97
  ],
86
98
  "devDependencies": {
87
- "@types/express": "^5.0.1"
99
+ "@types/express": "^5.0.2"
88
100
  }
89
101
  }