@cyanheads/git-mcp-server 2.0.10 → 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 +7 -6
- package/dist/mcp-server/server.js +26 -23
- package/dist/mcp-server/tools/gitInit/logic.js +3 -5
- package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +43 -16
- package/dist/mcp-server/tools/gitSetWorkingDir/registration.js +1 -1
- package/dist/mcp-server/tools/gitWorktree/index.js +7 -0
- package/dist/mcp-server/tools/gitWorktree/logic.js +239 -0
- package/dist/mcp-server/tools/gitWorktree/registration.js +67 -0
- package/dist/mcp-server/transports/authentication/authMiddleware.js +127 -105
- package/dist/mcp-server/transports/httpTransport.js +16 -2
- package/dist/mcp-server/transports/stdioTransport.js +2 -2
- package/dist/utils/internal/logger.js +74 -39
- package/package.json +32 -20
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Git MCP Server
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
|
-
[](https://modelcontextprotocol.io/)
|
|
5
|
+
[](./CHANGELOG.md)
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[](https://github.com/cyanheads/git-mcp-server/issues)
|
|
8
8
|
[](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.
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
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 {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
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 {
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
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 (
|
|
69
|
-
logger.
|
|
70
|
-
throw new McpError(BaseErrorCode.
|
|
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:
|
|
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
|
|
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 (
|
|
7
|
+
* in the configuration (`config.mcpAuthSecretKey`).
|
|
8
8
|
*
|
|
9
|
-
* If the token is valid,
|
|
10
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return;
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
let errorMessage = 'Invalid token'; // Default error message.
|
|
145
|
+
let errorMessage = "Invalid token";
|
|
119
146
|
if (error instanceof jwt.TokenExpiredError) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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(
|
|
159
|
+
logger.error("Authentication failed: Unexpected error during token verification.", { ...context, error: error.message });
|
|
135
160
|
}
|
|
136
161
|
else {
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
@@ -422,8 +436,8 @@ export async function startHttpTransport(createServerInstanceFn, context) {
|
|
|
422
436
|
// Determine protocol for logging (basic assumption based on HSTS possibility)
|
|
423
437
|
const protocol = config.environment === 'production' ? 'https' : 'http';
|
|
424
438
|
const serverAddress = `${protocol}://${config.mcpHttpHost}:${actualPort}${MCP_ENDPOINT_PATH}`;
|
|
425
|
-
// Use
|
|
426
|
-
|
|
439
|
+
// Use logger.notice for startup message to ensure MCP compliance and proper handling by clients.
|
|
440
|
+
logger.notice(`\n🚀 MCP Server running in HTTP mode at: ${serverAddress}\n (MCP Spec: 2025-03-26 Streamable HTTP Transport)\n`, transportContext);
|
|
427
441
|
}
|
|
428
442
|
catch (err) {
|
|
429
443
|
logger.fatal('HTTP server failed to start after multiple port retries.', { ...transportContext, error: err instanceof Error ? err.message : String(err) });
|
|
@@ -74,8 +74,8 @@ export async function connectStdioTransport(server, context) {
|
|
|
74
74
|
await server.connect(transport);
|
|
75
75
|
// Log successful connection. The server is now ready to process messages via stdio.
|
|
76
76
|
logger.info('MCP Server connected and listening via stdio transport.', operationContext);
|
|
77
|
-
// Use
|
|
78
|
-
|
|
77
|
+
// Use logger.notice for startup message to ensure MCP compliance and proper handling by clients.
|
|
78
|
+
logger.notice(`\n🚀 MCP Server running in STDIO mode.\n (MCP Spec: 2025-03-26 Stdio Transport)\n`, operationContext);
|
|
79
79
|
}
|
|
80
80
|
catch (err) {
|
|
81
81
|
// Catch and handle any critical errors during the transport connection setup.
|
|
@@ -31,8 +31,40 @@ const logsDir = path.join(projectRoot, 'logs');
|
|
|
31
31
|
const resolvedLogsDir = path.resolve(logsDir);
|
|
32
32
|
const isLogsDirSafe = resolvedLogsDir === projectRoot || resolvedLogsDir.startsWith(projectRoot + path.sep);
|
|
33
33
|
if (!isLogsDirSafe) {
|
|
34
|
-
// Use console.error
|
|
35
|
-
console
|
|
34
|
+
// Use console.error for critical pre-init errors.
|
|
35
|
+
// Only log to console if TTY to avoid polluting stdout for stdio MCP clients.
|
|
36
|
+
if (process.stdout.isTTY) {
|
|
37
|
+
console.error(`FATAL: logs directory "${resolvedLogsDir}" is outside project root "${projectRoot}". File logging disabled.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Helper function to create the Winston console format.
|
|
42
|
+
* This is extracted to avoid duplication between initialize and setLevel.
|
|
43
|
+
*/
|
|
44
|
+
function createWinstonConsoleFormat() {
|
|
45
|
+
return winston.format.combine(winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
|
46
|
+
let metaString = '';
|
|
47
|
+
const metaCopy = { ...meta };
|
|
48
|
+
if (metaCopy.error && typeof metaCopy.error === 'object') {
|
|
49
|
+
const errorObj = metaCopy.error;
|
|
50
|
+
if (errorObj.message)
|
|
51
|
+
metaString += `\n Error: ${errorObj.message}`;
|
|
52
|
+
if (errorObj.stack)
|
|
53
|
+
metaString += `\n Stack: ${String(errorObj.stack).split('\n').map((l) => ` ${l}`).join('\n')}`;
|
|
54
|
+
delete metaCopy.error;
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(metaCopy).length > 0) {
|
|
57
|
+
try {
|
|
58
|
+
const remainingMetaJson = JSON.stringify(metaCopy, null, 2);
|
|
59
|
+
if (remainingMetaJson !== '{}')
|
|
60
|
+
metaString += `\n Meta: ${remainingMetaJson}`;
|
|
61
|
+
}
|
|
62
|
+
catch (stringifyError) {
|
|
63
|
+
metaString += `\n Meta: [Error stringifying metadata: ${stringifyError.message}]`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return `${timestamp} ${level}: ${message}${metaString}`;
|
|
67
|
+
}));
|
|
36
68
|
}
|
|
37
69
|
/**
|
|
38
70
|
* Singleton Logger wrapping Winston, adapted for MCP.
|
|
@@ -53,21 +85,25 @@ class Logger {
|
|
|
53
85
|
*/
|
|
54
86
|
async initialize(level = 'info') {
|
|
55
87
|
if (this.initialized) {
|
|
56
|
-
|
|
88
|
+
this.warning('Logger already initialized.', { loggerSetup: true });
|
|
57
89
|
return;
|
|
58
90
|
}
|
|
59
91
|
this.currentMcpLevel = level;
|
|
60
92
|
this.currentWinstonLevel = mcpToWinstonLevel[level];
|
|
93
|
+
let logsDirCreatedMessage = null;
|
|
61
94
|
// Ensure logs directory exists
|
|
62
95
|
if (isLogsDirSafe) {
|
|
63
96
|
try {
|
|
64
97
|
if (!fs.existsSync(resolvedLogsDir)) {
|
|
65
98
|
fs.mkdirSync(resolvedLogsDir, { recursive: true });
|
|
66
|
-
|
|
99
|
+
logsDirCreatedMessage = `Created logs directory: ${resolvedLogsDir}`;
|
|
67
100
|
}
|
|
68
101
|
}
|
|
69
102
|
catch (err) {
|
|
70
|
-
console
|
|
103
|
+
// Conditional console output for pre-init errors to avoid issues with stdio MCP clients.
|
|
104
|
+
if (process.stdout.isTTY) {
|
|
105
|
+
console.error(`Error creating logs directory at ${resolvedLogsDir}: ${err.message}. File logging disabled.`);
|
|
106
|
+
}
|
|
71
107
|
}
|
|
72
108
|
}
|
|
73
109
|
// Common format for files
|
|
@@ -78,43 +114,26 @@ class Logger {
|
|
|
78
114
|
transports.push(new winston.transports.File({ filename: path.join(resolvedLogsDir, 'error.log'), level: 'error', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'warn.log'), level: 'warn', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'info.log'), level: 'info', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'debug.log'), level: 'debug', format: fileFormat }), new winston.transports.File({ filename: path.join(resolvedLogsDir, 'combined.log'), format: fileFormat }));
|
|
79
115
|
}
|
|
80
116
|
else {
|
|
81
|
-
|
|
117
|
+
// Conditional console output for pre-init warnings.
|
|
118
|
+
if (process.stdout.isTTY) {
|
|
119
|
+
console.warn("File logging disabled due to unsafe logs directory path.");
|
|
120
|
+
}
|
|
82
121
|
}
|
|
122
|
+
let consoleLoggingEnabledMessage = null;
|
|
123
|
+
let consoleLoggingSkippedMessage = null;
|
|
83
124
|
// Conditionally add Console transport only if:
|
|
84
125
|
// 1. MCP level is 'debug'
|
|
85
126
|
// 2. stdout is a TTY (interactive terminal, not piped)
|
|
86
127
|
if (this.currentMcpLevel === 'debug' && process.stdout.isTTY) {
|
|
87
|
-
const consoleFormat =
|
|
88
|
-
let metaString = '';
|
|
89
|
-
const metaCopy = { ...meta };
|
|
90
|
-
if (metaCopy.error && typeof metaCopy.error === 'object') {
|
|
91
|
-
const errorObj = metaCopy.error;
|
|
92
|
-
if (errorObj.message)
|
|
93
|
-
metaString += `\n Error: ${errorObj.message}`;
|
|
94
|
-
if (errorObj.stack)
|
|
95
|
-
metaString += `\n Stack: ${String(errorObj.stack).split('\n').map((l) => ` ${l}`).join('\n')}`;
|
|
96
|
-
delete metaCopy.error;
|
|
97
|
-
}
|
|
98
|
-
if (Object.keys(metaCopy).length > 0) {
|
|
99
|
-
try {
|
|
100
|
-
const remainingMetaJson = JSON.stringify(metaCopy, null, 2);
|
|
101
|
-
if (remainingMetaJson !== '{}')
|
|
102
|
-
metaString += `\n Meta: ${remainingMetaJson}`;
|
|
103
|
-
}
|
|
104
|
-
catch (stringifyError) {
|
|
105
|
-
metaString += `\n Meta: [Error stringifying metadata: ${stringifyError.message}]`;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return `${timestamp} ${level}: ${message}${metaString}`;
|
|
109
|
-
}));
|
|
128
|
+
const consoleFormat = createWinstonConsoleFormat();
|
|
110
129
|
transports.push(new winston.transports.Console({
|
|
111
130
|
level: 'debug',
|
|
112
131
|
format: consoleFormat,
|
|
113
132
|
}));
|
|
114
|
-
|
|
133
|
+
consoleLoggingEnabledMessage = 'Console logging enabled at level: debug (stdout is TTY)';
|
|
115
134
|
}
|
|
116
135
|
else if (this.currentMcpLevel === 'debug' && !process.stdout.isTTY) {
|
|
117
|
-
|
|
136
|
+
consoleLoggingSkippedMessage = 'Console logging skipped: Level is debug, but stdout is not a TTY (likely stdio transport).';
|
|
118
137
|
}
|
|
119
138
|
// Create logger with the initial Winston level and configured transports
|
|
120
139
|
this.winstonLogger = winston.createLogger({
|
|
@@ -122,9 +141,19 @@ class Logger {
|
|
|
122
141
|
transports,
|
|
123
142
|
exitOnError: false
|
|
124
143
|
});
|
|
144
|
+
// Log deferred messages now that winstonLogger is initialized
|
|
145
|
+
if (logsDirCreatedMessage) {
|
|
146
|
+
this.info(logsDirCreatedMessage, { loggerSetup: true });
|
|
147
|
+
}
|
|
148
|
+
if (consoleLoggingEnabledMessage) {
|
|
149
|
+
this.info(consoleLoggingEnabledMessage, { loggerSetup: true });
|
|
150
|
+
}
|
|
151
|
+
if (consoleLoggingSkippedMessage) {
|
|
152
|
+
this.info(consoleLoggingSkippedMessage, { loggerSetup: true });
|
|
153
|
+
}
|
|
125
154
|
this.initialized = true;
|
|
126
155
|
await Promise.resolve(); // Yield to event loop
|
|
127
|
-
this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${process.stdout.isTTY && this.currentMcpLevel === 'debug' ? 'enabled' : 'disabled'}
|
|
156
|
+
this.info(`Logger initialized. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${process.stdout.isTTY && this.currentMcpLevel === 'debug' ? 'enabled' : 'disabled'}`, { loggerSetup: true });
|
|
128
157
|
}
|
|
129
158
|
/**
|
|
130
159
|
* Sets the function used to send MCP 'notifications/message'.
|
|
@@ -132,14 +161,17 @@ class Logger {
|
|
|
132
161
|
setMcpNotificationSender(sender) {
|
|
133
162
|
this.mcpNotificationSender = sender;
|
|
134
163
|
const status = sender ? 'enabled' : 'disabled';
|
|
135
|
-
this.info(`MCP notification sending ${status}
|
|
164
|
+
this.info(`MCP notification sending ${status}.`, { loggerSetup: true });
|
|
136
165
|
}
|
|
137
166
|
/**
|
|
138
167
|
* Dynamically sets the minimum logging level.
|
|
139
168
|
*/
|
|
140
169
|
setLevel(newLevel) {
|
|
141
170
|
if (!this.ensureInitialized()) {
|
|
142
|
-
console
|
|
171
|
+
// Conditional console output if logger not usable.
|
|
172
|
+
if (process.stdout.isTTY) {
|
|
173
|
+
console.error("Cannot set level: Logger not initialized.");
|
|
174
|
+
}
|
|
143
175
|
return;
|
|
144
176
|
}
|
|
145
177
|
if (!(newLevel in mcpLevelSeverity)) {
|
|
@@ -155,17 +187,17 @@ class Logger {
|
|
|
155
187
|
const shouldHaveConsole = newLevel === 'debug' && process.stdout.isTTY;
|
|
156
188
|
if (shouldHaveConsole && !consoleTransport) {
|
|
157
189
|
// Add console transport
|
|
158
|
-
const consoleFormat =
|
|
190
|
+
const consoleFormat = createWinstonConsoleFormat();
|
|
159
191
|
this.winstonLogger.add(new winston.transports.Console({ level: 'debug', format: consoleFormat }));
|
|
160
|
-
this.info('Console logging dynamically enabled.');
|
|
192
|
+
this.info('Console logging dynamically enabled.', { loggerSetup: true });
|
|
161
193
|
}
|
|
162
194
|
else if (!shouldHaveConsole && consoleTransport) {
|
|
163
195
|
// Remove console transport
|
|
164
196
|
this.winstonLogger.remove(consoleTransport);
|
|
165
|
-
this.info('Console logging dynamically disabled.');
|
|
197
|
+
this.info('Console logging dynamically disabled.', { loggerSetup: true });
|
|
166
198
|
}
|
|
167
199
|
if (oldLevel !== newLevel) {
|
|
168
|
-
this.info(`Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${shouldHaveConsole ? 'enabled' : 'disabled'}
|
|
200
|
+
this.info(`Log level changed. File logging level: ${this.currentWinstonLevel}. MCP logging level: ${this.currentMcpLevel}. Console logging: ${shouldHaveConsole ? 'enabled' : 'disabled'}`, { loggerSetup: true });
|
|
169
201
|
}
|
|
170
202
|
}
|
|
171
203
|
/** Get singleton instance. */
|
|
@@ -178,7 +210,10 @@ class Logger {
|
|
|
178
210
|
/** Ensures the logger has been initialized. */
|
|
179
211
|
ensureInitialized() {
|
|
180
212
|
if (!this.initialized || !this.winstonLogger) {
|
|
181
|
-
console
|
|
213
|
+
// Conditional console output if logger not usable.
|
|
214
|
+
if (process.stdout.isTTY) {
|
|
215
|
+
console.warn('Logger not initialized; message dropped.');
|
|
216
|
+
}
|
|
182
217
|
return false;
|
|
183
218
|
}
|
|
184
219
|
return true;
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/git-mcp-server",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "An MCP (Model Context Protocol) server
|
|
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": "
|
|
20
|
+
"git-mcp-server": "dist/index.js"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist"
|
|
@@ -37,20 +37,20 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/inspector": "^0.
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
40
|
+
"@modelcontextprotocol/inspector": "^0.13.0",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
42
42
|
"@types/jsonwebtoken": "^9.0.9",
|
|
43
|
-
"@types/node": "^22.15.
|
|
43
|
+
"@types/node": "^22.15.21",
|
|
44
44
|
"@types/sanitize-html": "^2.16.0",
|
|
45
|
-
"@types/validator": "^13.15.
|
|
46
|
-
"chrono-node": "
|
|
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.
|
|
51
|
+
"openai": "^4.103.0",
|
|
52
52
|
"partial-json": "^0.1.7",
|
|
53
|
-
"sanitize-html": "^2.
|
|
53
|
+
"sanitize-html": "^2.17.0",
|
|
54
54
|
"tiktoken": "^1.0.21",
|
|
55
55
|
"ts-node": "^10.9.2",
|
|
56
56
|
"typescript": "^5.8.3",
|
|
@@ -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.
|
|
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
|
-
"
|
|
78
|
-
"
|
|
84
|
+
"llm-tools",
|
|
85
|
+
"merge",
|
|
79
86
|
"pull",
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
87
|
+
"push",
|
|
88
|
+
"rebase",
|
|
89
|
+
"remote",
|
|
90
|
+
"reset",
|
|
91
|
+
"stash",
|
|
92
|
+
"status",
|
|
93
|
+
"tag",
|
|
94
|
+
"worktree",
|
|
83
95
|
"ai-agent",
|
|
84
|
-
"
|
|
96
|
+
"automation"
|
|
85
97
|
],
|
|
86
98
|
"devDependencies": {
|
|
87
|
-
"@types/express": "^5.0.
|
|
99
|
+
"@types/express": "^5.0.2"
|
|
88
100
|
}
|
|
89
101
|
}
|