@cyanheads/git-mcp-server 2.0.15 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-^5.8.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol](https://img.shields.io/badge/MCP%20SDK-^1.12.1-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-2.0.15-blue.svg)](./CHANGELOG.md)
5
+ [![Version](https://img.shields.io/badge/Version-2.1.0-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)
@@ -42,7 +42,7 @@ import { initializeGitStashStateAccessors, registerGitStashTool } from './tools/
42
42
  import { initializeGitStatusStateAccessors, registerGitStatusTool } from './tools/gitStatus/index.js';
43
43
  import { initializeGitTagStateAccessors, registerGitTagTool } from './tools/gitTag/index.js';
44
44
  import { initializeGitWorktreeStateAccessors, registerGitWorktreeTool } from './tools/gitWorktree/index.js';
45
- import { registerGitWrapupInstructionsTool } from './tools/gitWrapupInstructions/index.js';
45
+ import { initializeGitWrapupInstructionsStateAccessors, registerGitWrapupInstructionsTool } from './tools/gitWrapupInstructions/index.js';
46
46
  // Import transport setup functions AND state accessors
47
47
  import { getHttpSessionWorkingDirectory, setHttpSessionWorkingDirectory, startHttpTransport } from './transports/httpTransport.js';
48
48
  import { connectStdioTransport, getStdioWorkingDirectory, setStdioWorkingDirectory } from './transports/stdioTransport.js';
@@ -160,7 +160,7 @@ async function createMcpServerInstance() {
160
160
  initializeGitStatusStateAccessors(getWorkingDirectory, getSessionIdFromContext);
161
161
  initializeGitTagStateAccessors(getWorkingDirectory, getSessionIdFromContext);
162
162
  initializeGitWorktreeStateAccessors(getWorkingDirectory, getSessionIdFromContext);
163
- // No state accessor initialization needed for gitWrapupInstructionsTool
163
+ initializeGitWrapupInstructionsStateAccessors(getWorkingDirectory, getSessionIdFromContext); // Added this line
164
164
  logger.debug('State accessors initialized successfully.', context);
165
165
  }
166
166
  catch (initError) {
@@ -18,72 +18,61 @@ export const GitStatusInputSchema = z.object({
18
18
  function parseGitStatusPorcelainV1(porcelainOutput) {
19
19
  const lines = porcelainOutput.trim().split('\n');
20
20
  const result = {
21
- currentBranch: null,
22
- staged: [],
23
- modified: [],
24
- untracked: [],
25
- conflicted: [],
26
- isClean: true, // Assume clean initially
21
+ current_branch: null,
22
+ staged_changes: {},
23
+ unstaged_changes: {},
24
+ untracked_files: [],
25
+ conflicted_files: [],
26
+ is_clean: true, // Assume clean initially
27
27
  };
28
28
  if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) {
29
- // If output is empty, it might mean no branch yet or truly clean
30
- // We'll refine branch detection below if possible
31
29
  return result;
32
30
  }
33
- // First line often contains branch info (e.g., ## master...origin/master)
34
31
  if (lines[0].startsWith('## ')) {
35
- const branchLine = lines.shift(); // Remove and process the branch line
36
- // Try matching standard branch format first (e.g., ## master...origin/master [ahead 1])
37
- // Make regex more specific: look for '...' or '[' after branch name, or end of line for simple branch name
32
+ const branchLine = lines.shift();
38
33
  const standardBranchMatch = branchLine.match(/^## ([^ ]+?)(?:\.\.\.| \[.*\]|$)/);
39
- // Try matching the 'No commits yet' format (e.g., ## No commits yet on master)
40
34
  const noCommitsMatch = branchLine.match(/^## No commits yet on (.+)/);
41
- // Try matching detached HEAD format (e.g., ## HEAD (no branch))
42
35
  const detachedMatch = branchLine.match(/^## HEAD \(no branch\)/);
43
36
  if (standardBranchMatch) {
44
- result.currentBranch = standardBranchMatch[1];
45
- // TODO: Optionally parse ahead/behind counts if needed from the full match
37
+ result.current_branch = standardBranchMatch[1];
46
38
  }
47
39
  else if (noCommitsMatch) {
48
- // More descriptive state: Branch exists but has no commits
49
- result.currentBranch = `${noCommitsMatch[1]} (no commits yet)`;
40
+ result.current_branch = `${noCommitsMatch[1]} (no commits yet)`;
50
41
  }
51
- else if (detachedMatch) { // Handle detached HEAD
52
- result.currentBranch = 'HEAD (detached)';
42
+ else if (detachedMatch) {
43
+ result.current_branch = 'HEAD (detached)';
53
44
  }
54
45
  else {
55
- // Fallback if branch line format is unexpected
56
46
  logger.warning('Could not parse branch information from line:', { branchLine });
57
- result.currentBranch = '(unknown)';
47
+ result.current_branch = '(unknown)';
58
48
  }
59
49
  }
60
50
  for (const line of lines) {
61
51
  if (!line)
62
- continue; // Skip empty lines if any
63
- result.isClean = false; // Any line indicates non-clean state
52
+ continue;
53
+ result.is_clean = false; // Any line indicates non-clean state
64
54
  const xy = line.substring(0, 2);
65
- const file = line.substring(3); // Path starts after 'XY '
66
- const stagedStatus = xy[0];
67
- const unstagedStatus = xy[1];
55
+ const file = line.substring(3);
56
+ const stagedStatusChar = xy[0];
57
+ const unstagedStatusChar = xy[1];
68
58
  // Handle untracked files
69
59
  if (xy === '??') {
70
- result.untracked.push(file);
60
+ result.untracked_files.push(file);
71
61
  continue;
72
62
  }
73
- // Handle conflicted files (complex statuses)
74
- if (stagedStatus === 'U' || unstagedStatus === 'U' || (stagedStatus === 'A' && unstagedStatus === 'A') || (stagedStatus === 'D' && unstagedStatus === 'D')) {
75
- result.conflicted.push(file);
76
- // Decide how to represent conflicts (could be more granular)
77
- if (!result.staged.some(f => f.file === file))
78
- result.staged.push({ status: 'Conflicted', file });
79
- if (!result.modified.some(f => f.file === file))
80
- result.modified.push({ status: 'Conflicted', file });
81
- continue;
63
+ // Handle conflicted files (unmerged paths)
64
+ // DD = both deleted, AU = added by us, UD = deleted by them, UA = added by them, DU = deleted by us
65
+ // AA = both added, UU = both modified
66
+ if (stagedStatusChar === 'U' || unstagedStatusChar === 'U' ||
67
+ (stagedStatusChar === 'D' && unstagedStatusChar === 'D') ||
68
+ (stagedStatusChar === 'A' && unstagedStatusChar === 'A')) {
69
+ result.conflicted_files.push(file);
70
+ continue; // Conflicted files are handled separately and not in staged/unstaged
82
71
  }
83
72
  // Handle staged changes (index status)
84
- if (stagedStatus !== ' ' && stagedStatus !== '?') {
85
- let statusDesc = 'Unknown Staged';
86
- switch (stagedStatus) {
73
+ if (stagedStatusChar !== ' ' && stagedStatusChar !== '?') {
74
+ let statusDesc = undefined;
75
+ switch (stagedStatusChar) {
87
76
  case 'M':
88
77
  statusDesc = 'Modified';
89
78
  break;
@@ -95,20 +84,25 @@ function parseGitStatusPorcelainV1(porcelainOutput) {
95
84
  break;
96
85
  case 'R':
97
86
  statusDesc = 'Renamed';
98
- break; // Often includes ' -> new_name' in file path
87
+ break;
99
88
  case 'C':
100
89
  statusDesc = 'Copied';
101
- break; // Often includes ' -> new_name' in file path
90
+ break;
102
91
  case 'T':
103
- statusDesc = 'Type Changed';
92
+ statusDesc = 'TypeChanged';
104
93
  break;
105
94
  }
106
- result.staged.push({ status: statusDesc, file });
95
+ if (statusDesc) {
96
+ if (!result.staged_changes[statusDesc]) {
97
+ result.staged_changes[statusDesc] = [];
98
+ }
99
+ result.staged_changes[statusDesc].push(file);
100
+ }
107
101
  }
108
102
  // Handle unstaged changes (worktree status)
109
- if (unstagedStatus !== ' ' && unstagedStatus !== '?') {
110
- let statusDesc = 'Unknown Unstaged';
111
- switch (unstagedStatus) {
103
+ if (unstagedStatusChar !== ' ' && unstagedStatusChar !== '?') {
104
+ let statusDesc = undefined;
105
+ switch (unstagedStatusChar) {
112
106
  case 'M':
113
107
  statusDesc = 'Modified';
114
108
  break;
@@ -116,18 +110,23 @@ function parseGitStatusPorcelainV1(porcelainOutput) {
116
110
  statusDesc = 'Deleted';
117
111
  break;
118
112
  case 'T':
119
- statusDesc = 'Type Changed';
113
+ statusDesc = 'TypeChanged';
120
114
  break;
121
- // Note: 'A' (Added) in unstaged usually means untracked ('??') handled above
115
+ // 'A' (Added but not committed) is handled by '??' (untracked)
116
+ // 'R' and 'C' in worktree without being staged are complex, often appear as deleted + untracked
122
117
  }
123
- // Avoid duplicating if already marked as conflicted
124
- if (!result.modified.some(f => f.file === file && f.status === 'Conflicted')) {
125
- result.modified.push({ status: statusDesc, file });
118
+ if (statusDesc) {
119
+ if (!result.unstaged_changes[statusDesc]) {
120
+ result.unstaged_changes[statusDesc] = [];
121
+ }
122
+ result.unstaged_changes[statusDesc].push(file);
126
123
  }
127
124
  }
128
125
  }
129
- // Final check for cleanliness
130
- result.isClean = result.staged.length === 0 && result.modified.length === 0 && result.untracked.length === 0 && result.conflicted.length === 0;
126
+ result.is_clean = Object.keys(result.staged_changes).length === 0 &&
127
+ Object.keys(result.unstaged_changes).length === 0 &&
128
+ result.untracked_files.length === 0 &&
129
+ result.conflicted_files.length === 0;
131
130
  return result;
132
131
  }
133
132
  /**
@@ -186,13 +185,17 @@ export async function getGitStatus(input, context // Add getter to context
186
185
  const structuredResult = parseGitStatusPorcelainV1(stdout);
187
186
  // If parsing resulted in clean state but no branch, re-check branch explicitly
188
187
  // This handles the case of an empty repo after init but before first commit
189
- if (structuredResult.isClean && !structuredResult.currentBranch) {
188
+ if (structuredResult.is_clean && !structuredResult.current_branch) {
190
189
  try {
191
190
  const branchCommand = `git -C "${targetPath}" rev-parse --abbrev-ref HEAD`;
192
191
  const { stdout: branchStdout } = await execAsync(branchCommand);
193
- const currentBranch = branchStdout.trim();
194
- if (currentBranch && currentBranch !== 'HEAD') {
195
- structuredResult.currentBranch = currentBranch;
192
+ const currentBranchName = branchStdout.trim(); // Renamed variable for clarity
193
+ if (currentBranchName && currentBranchName !== 'HEAD') {
194
+ structuredResult.current_branch = currentBranchName;
195
+ }
196
+ else if (currentBranchName === 'HEAD' && !structuredResult.current_branch) {
197
+ // If rev-parse returns HEAD and we still don't have a branch (e.g. detached from no-commits branch)
198
+ structuredResult.current_branch = 'HEAD (detached)';
196
199
  }
197
200
  }
198
201
  catch (branchError) {
@@ -16,7 +16,7 @@ export function initializeGitStatusStateAccessors(getWdFn, getSidFn) {
16
16
  logger.info('State accessors initialized for git_status tool registration.');
17
17
  }
18
18
  const TOOL_NAME = 'git_status';
19
- const TOOL_DESCRIPTION = 'Retrieves the status of a Git repository. Shows the working tree status including tracked/untracked files, modifications, staged changes, and current branch information. Returns the status as a JSON object.';
19
+ const TOOL_DESCRIPTION = 'Retrieves the status of a Git repository. Returns a JSON object detailing the current branch, cleanliness, and changes. Staged and unstaged changes are grouped by status (e.g., Added, Modified), alongside lists of untracked and conflicted files.';
20
20
  /**
21
21
  * Registers the git_status tool with the MCP server.
22
22
  * Uses the high-level server.tool() method for registration, schema validation, and routing.
@@ -1,3 +1,3 @@
1
- export { registerGitWrapupInstructionsTool } from './registration.js';
2
- // This tool does not require session-specific state accessors like getWorkingDirectory,
3
- // so no initialize...StateAccessors function is needed or exported here.
1
+ export { registerGitWrapupInstructionsTool, initializeGitWrapupInstructionsStateAccessors } from './registration.js';
2
+ // This tool now requires session-specific state accessors (getWorkingDirectory, getSessionId)
3
+ // to fetch git status, so initializeGitWrapupInstructionsStateAccessors is exported for server setup.
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { logger } from '../../../utils/index.js'; // Added logger
3
+ import { getGitStatus } from '../gitStatus/logic.js'; // Corrected path
2
4
  // Define the input schema
3
5
  export const GitWrapupInstructionsInputSchema = z.object({
4
6
  acknowledgement: z.enum(['Y', 'y', 'Yes', 'yes'], {
@@ -24,13 +26,36 @@ Note: Be sure to set 'git_set_working_dir' if not already set.
24
26
  * @param {RequestContext} _context - The request context (included for consistency, not used in this simple logic).
25
27
  * @returns {Promise<GitWrapupInstructionsResult>} A promise that resolves with the wrap-up instructions.
26
28
  */
27
- export async function getWrapupInstructions(input, _context // Included for structural consistency, not used by this simple tool
28
- ) {
29
+ export async function getWrapupInstructions(input,
30
+ // The context is now expected to be enhanced by the registration layer
31
+ // to include session-specific methods like getWorkingDirectory.
32
+ context) {
29
33
  let finalInstructions = WRAPUP_INSTRUCTIONS;
30
34
  if (input.updateAgentMetaFiles && ['Y', 'y', 'Yes', 'yes'].includes(input.updateAgentMetaFiles)) {
31
35
  finalInstructions += ` Extra request: review and update if needed the .clinerules and claude.md files if present.`;
32
36
  }
37
+ let statusResult = undefined;
38
+ let statusError = undefined;
39
+ const workingDir = context.getWorkingDirectory();
40
+ if (workingDir) {
41
+ try {
42
+ // The `getGitStatus` function expects `path` and a context with `getWorkingDirectory`.
43
+ // Passing `path: '.'` signals `getGitStatus` to use the working directory from the context.
44
+ // The `registration.ts` for this tool will be responsible for ensuring `context.getWorkingDirectory` is correctly supplied.
45
+ statusResult = await getGitStatus({ path: '.' }, context);
46
+ }
47
+ catch (error) {
48
+ logger.warning(`Failed to get git status while generating wrapup instructions (working dir: ${workingDir}). Tool will proceed without it.`, { ...context, tool: 'gitWrapupInstructions', originalError: error.message });
49
+ statusError = error instanceof Error ? error.message : String(error);
50
+ }
51
+ }
52
+ else {
53
+ logger.info('No working directory set for session, skipping git status for wrapup instructions.', { ...context, tool: 'gitWrapupInstructions' });
54
+ statusError = 'No working directory set for session, git status skipped.';
55
+ }
33
56
  return {
34
57
  instructions: finalInstructions,
58
+ gitStatus: statusResult,
59
+ gitStatusError: statusError,
35
60
  };
36
61
  }
@@ -1,42 +1,78 @@
1
1
  import { BaseErrorCode } from '../../../types-global/errors.js';
2
2
  import { ErrorHandler, logger, requestContextService } from '../../../utils/index.js';
3
3
  import { getWrapupInstructions, GitWrapupInstructionsInputSchema, } from './logic.js';
4
+ let _getWorkingDirectory;
5
+ let _getSessionId;
6
+ /**
7
+ * Initializes the state accessors needed by the git_wrapup_instructions tool.
8
+ * This should be called once during server setup by server.ts.
9
+ * @param getWdFn - Function to get the working directory for a session.
10
+ * @param getSidFn - Function to get the session ID from context.
11
+ */
12
+ export function initializeGitWrapupInstructionsStateAccessors(getWdFn, getSidFn) {
13
+ _getWorkingDirectory = getWdFn;
14
+ _getSessionId = getSidFn;
15
+ logger.info('State accessors initialized for git_wrapup_instructions tool registration.');
16
+ }
4
17
  const TOOL_NAME = 'git_wrapup_instructions';
5
- const TOOL_DESCRIPTION = 'Provides a standard Git wrap-up workflow. This involves reviewing changes with `git_diff`, updating documentation (README, CHANGELOG), and making logical, descriptive commits using the `git_commit` tool.';
18
+ const TOOL_DESCRIPTION = 'Provides a standard Git wrap-up workflow. This involves reviewing changes with `git_diff`, updating documentation (README, CHANGELOG), and making logical, descriptive commits using the `git_commit` tool. The tool\'s response also includes the current `git status` output.';
6
19
  /**
7
20
  * Registers the git_wrapup_instructions tool with the MCP server.
8
21
  *
9
22
  * @param {McpServer} server - The McpServer instance to register the tool with.
10
23
  * @returns {Promise<void>}
11
- * @throws {Error} If registration fails.
24
+ * @throws {Error} If registration fails or state accessors are not initialized.
12
25
  */
13
26
  export const registerGitWrapupInstructionsTool = async (server) => {
27
+ if (!_getWorkingDirectory || !_getSessionId) {
28
+ throw new Error('State accessors for git_wrapup_instructions must be initialized before registration.');
29
+ }
14
30
  const operation = 'registerGitWrapupInstructionsTool';
15
- const context = requestContextService.createRequestContext({ operation });
31
+ // Context for the registration operation itself
32
+ const registrationOpContext = requestContextService.createRequestContext({ operation });
16
33
  await ErrorHandler.tryCatch(async () => {
17
- server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitWrapupInstructionsInputSchema.shape, // Empty schema shape
18
- async (validatedArgs, callContext) => {
34
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitWrapupInstructionsInputSchema.shape, async (validatedArgs, callContext) => {
19
35
  const toolOperation = 'tool:git_wrapup_instructions';
20
- // Pass callContext as parentContext for consistent context chaining
21
- const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
22
- logger.info(`Executing tool: ${TOOL_NAME}`, requestContext);
36
+ // Create a base RequestContext for this specific tool call,
37
+ // potentially linking to the callContext provided by the McpServer.
38
+ // Pass callContext directly; createRequestContext will handle it appropriately
39
+ // (e.g., by trying to extract a requestId or sessionId if relevant for linking).
40
+ const baseRequestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
41
+ // Retrieve the session ID using the initialized accessor.
42
+ // _getSessionId (which is getSessionIdFromContext from server.ts) expects Record<string, any>.
43
+ // callContext from server.tool() is compatible with Record<string, any>.
44
+ const sessionId = _getSessionId(callContext);
45
+ // Create the session-specific getWorkingDirectory function.
46
+ const getWorkingDirectoryForSession = () => {
47
+ // _getWorkingDirectory is guaranteed to be defined by the check at the start of register function.
48
+ return _getWorkingDirectory(sessionId);
49
+ };
50
+ // Construct the logicContext to be passed to the tool's core logic.
51
+ // This includes the base request context properties, the session ID,
52
+ // and the specific getWorkingDirectory function for this session.
53
+ const logicContext = {
54
+ ...baseRequestContext,
55
+ sessionId: sessionId,
56
+ getWorkingDirectory: getWorkingDirectoryForSession,
57
+ };
58
+ logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
23
59
  return await ErrorHandler.tryCatch(async () => {
24
- const result = await getWrapupInstructions(validatedArgs, requestContext // Pass the created requestContext
25
- );
60
+ // Call the core logic function with validated arguments and the prepared logicContext.
61
+ const result = await getWrapupInstructions(validatedArgs, logicContext);
26
62
  const resultContent = {
27
63
  type: 'text',
28
64
  text: JSON.stringify(result, null, 2),
29
65
  contentType: 'application/json',
30
66
  };
31
- logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, requestContext);
67
+ logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
32
68
  return { content: [resultContent] };
33
69
  }, {
34
70
  operation: toolOperation,
35
- context: requestContext,
71
+ context: logicContext, // Use the enhanced logicContext for error reporting
36
72
  input: validatedArgs,
37
73
  errorCode: BaseErrorCode.INTERNAL_ERROR,
38
74
  });
39
75
  });
40
- logger.info(`Tool registered: ${TOOL_NAME}`, context);
41
- }, { operation, context, critical: true });
76
+ logger.info(`Tool registered: ${TOOL_NAME}`, registrationOpContext); // Use context for registration operation
77
+ }, { operation, context: registrationOpContext, critical: true }); // Use context for registration operation
42
78
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/git-mcp-server",
3
- "version": "2.0.15",
3
+ "version": "2.1.0",
4
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",
@@ -40,15 +40,15 @@
40
40
  "@modelcontextprotocol/inspector": "^0.13.0",
41
41
  "@modelcontextprotocol/sdk": "^1.12.1",
42
42
  "@types/jsonwebtoken": "^9.0.9",
43
- "@types/node": "^22.15.27",
43
+ "@types/node": "^22.15.29",
44
44
  "@types/sanitize-html": "^2.16.0",
45
45
  "@types/validator": "^13.15.1",
46
46
  "chrono-node": "2.8.0",
47
47
  "dotenv": "^16.5.0",
48
48
  "express": "^5.1.0",
49
- "ignore": "^7.0.4",
49
+ "ignore": "^7.0.5",
50
50
  "jsonwebtoken": "^9.0.2",
51
- "openai": "^5.0.1",
51
+ "openai": "^5.0.2",
52
52
  "partial-json": "^0.1.7",
53
53
  "sanitize-html": "^2.17.0",
54
54
  "tiktoken": "^1.0.21",
@@ -58,7 +58,7 @@
58
58
  "winston": "^3.17.0",
59
59
  "winston-daily-rotate-file": "^5.0.0",
60
60
  "yargs": "^18.0.0",
61
- "zod": "^3.25.42"
61
+ "zod": "^3.25.49"
62
62
  },
63
63
  "keywords": [
64
64
  "typescript",