Skip to content

Commit dba44ff

Browse files
authored
feat(discovery): add auto-discovery from git remote (#67)
* feat(discovery): add auto-discovery from git remote Add --auto flag that automatically detects GitLab configuration from the current git repository: - Parse git remote URL to extract host and project path - Support SSH (git@host:path) and HTTPS formats - Support nested groups (group/subgroup/project) - Match discovered host to user-defined profiles - Load project-level configs from .gitlab-mcp/ - Set default project/namespace context New CLI flags: - --auto: Enable auto-discovery mode - --cwd <path>: Custom repository path - --dry-run: Show what would be detected without applying - --remote <name>: Use specific git remote (default: origin) Closes #57 * test(discovery): add edge case tests for error handling and formatting Add tests for: - Git config read error handling - Unparseable remote URL handling - Existing default context preservation - Single-segment project path namespace calculation - Profile display without authType or readOnly - Preset display without description or scope - Project profile display without extends - Module index exports verification * refactor(discovery): remove misleading regex comment The comment about avoiding unnecessary escape was misleading. The pattern [^[] correctly matches any character except [. Using [^\[] would trigger ESLint no-useless-escape error. * fix(discovery): preserve port in URL parsing and avoid duplicate profile loading - Include port number in host field for SSH and HTTPS URLs with non-standard ports - Track auto-discovered profile name to skip redundant profile loading when --profile specifies the same profile that was already applied via --auto * docs(discovery): document configuration priority and auto-discovery usage - Add Auto-Discovery section to README with CLI usage examples - Document configuration priority: --profile > project config > auto-discovered - Refactor main.ts to implement correct priority order - Auto-discovery profile is used only when no --profile specified - Warnings logged when auto-discovered profile is overridden * fix(discovery): use state-based parser for git config and fix namespace consistency - Replace regex-based git config parser with line-by-line state machine to correctly handle multiline configurations where url may not immediately follow the remote header - Fix namespace extraction for single-segment paths: now sets namespace to project path (consistent with auto.ts setDefaultContext) - Add tests for multiline git config parsing scenarios * test(discovery): add edge case tests for error handling and empty scope - Test non-Error rejection in loadAndApplyProfile (string rejection) - Test preset with empty scope object (no project/namespace/projects) - Test multiline git config with url not immediately after remote header * test(main): add comprehensive unit tests for main.ts entry point Rewrote main.test.ts to use jest.isolateModules for proper code coverage. Tests now actually execute main.ts code instead of just simulating behavior. Coverage improved from 0% to 100% statements, 82% branches. Tests cover: - Basic server startup and error handling - --show-project-config flag behavior - --auto flag with auto-discovery - Profile priority (CLI > auto-discovered > default) - Project config loading and error handling - Default context setting from auto-discovery * refactor(utils): add extractNamespaceFromPath utility function - Created extractNamespaceFromPath() in src/utils/namespace.ts - Updated main.ts to use shared utility for namespace extraction - Updated auto.ts to use shared utility for namespace extraction - Added unit tests for extractNamespaceFromPath() * refactor(discovery): use extractNamespaceFromPath in formatDiscoveryResult Replace inline namespace extraction with shared utility function.
1 parent f467faf commit dba44ff

File tree

15 files changed

+3095
-258
lines changed

15 files changed

+3095
-258
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,55 @@ docker run -i --rm \
187187
}
188188
```
189189

190+
## Auto-Discovery
191+
192+
The `--auto` flag automatically detects GitLab configuration from the current git repository's remote URL.
193+
194+
### Usage
195+
196+
```bash
197+
# Auto-discover from current directory
198+
gitlab-mcp --auto
199+
200+
# Auto-discover from specific directory
201+
gitlab-mcp --auto --cwd /path/to/repo
202+
203+
# Use specific remote (default: origin)
204+
gitlab-mcp --auto --remote upstream
205+
206+
# Dry-run: see what would be detected without applying
207+
gitlab-mcp --auto --dry-run
208+
```
209+
210+
### Configuration Priority
211+
212+
When multiple configuration sources are available, they are applied in this order (highest to lowest priority):
213+
214+
| Priority | Source | What it provides |
215+
|----------|--------|------------------|
216+
| 1 (highest) | `--profile` CLI argument | Selects user profile (host, auth, features) |
217+
| 2 | Project config files (`.gitlab-mcp/`) | Adds restrictions and tool selection |
218+
| 3 (lowest) | Auto-discovered profile | Fallback profile selection from git remote |
219+
220+
**Important notes:**
221+
222+
- **`--profile` always wins**: If you specify `--profile work`, it will be used even if auto-discovery detected a different profile. A warning is logged when this happens.
223+
- **Project config adds restrictions**: The `.gitlab-mcp/` directory configuration (preset.yaml, profile.yaml) adds restrictions ON TOP of the selected profile - it doesn't replace it.
224+
- **Auto-discovery sets defaults**: Even when a higher-priority source is used, auto-discovery still sets `GITLAB_DEFAULT_PROJECT` and `GITLAB_DEFAULT_NAMESPACE` from the git remote.
225+
226+
### How Auto-Discovery Works
227+
228+
1. Parses git remote URL (SSH or HTTPS format)
229+
2. Extracts GitLab host and project path
230+
3. Matches host to configured user profiles
231+
4. Sets default project context for convenience
232+
233+
**Supported URL formats:**
234+
- SSH: `[email protected]:group/project.git`
235+
- SSH with port: `ssh://[email protected]:2222/group/project.git`
236+
- HTTPS: `https://gitlab.company.com/group/project.git`
237+
- HTTPS with port: `https://gitlab.company.com:8443/group/project.git`
238+
190239
## Transport Modes
191240

192241
The GitLab MCP Server automatically selects the appropriate transport mode based on your configuration:

docs/public/CNAME

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
docs.gitlab-mcp.sw.foundation

src/cli-utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ export interface CliArgs {
1313
profileName?: string;
1414
noProjectConfig: boolean;
1515
showProjectConfig: boolean;
16+
/** Enable auto-discovery from git remote */
17+
auto: boolean;
18+
/** Custom working directory for auto-discovery */
19+
cwd?: string;
20+
/** Dry run - show what would be detected without applying */
21+
dryRun: boolean;
22+
/** Git remote name to use (default: origin) */
23+
remoteName?: string;
1624
}
1725

1826
/**
@@ -25,6 +33,8 @@ export function parseCliArgs(argv: string[] = process.argv): CliArgs {
2533
const result: CliArgs = {
2634
noProjectConfig: false,
2735
showProjectConfig: false,
36+
auto: false,
37+
dryRun: false,
2838
};
2939

3040
let profileCount = 0;
@@ -48,6 +58,26 @@ export function parseCliArgs(argv: string[] = process.argv): CliArgs {
4858
result.noProjectConfig = true;
4959
} else if (arg === "--show-project-config") {
5060
result.showProjectConfig = true;
61+
} else if (arg === "--auto") {
62+
result.auto = true;
63+
} else if (arg === "--cwd") {
64+
const value = args[i + 1];
65+
if (!value || value.startsWith("--")) {
66+
logger.error("--cwd requires a directory path (e.g., --cwd /path/to/repo)");
67+
throw new Error("--cwd requires a directory path");
68+
}
69+
result.cwd = value;
70+
i++; // Skip value
71+
} else if (arg === "--dry-run") {
72+
result.dryRun = true;
73+
} else if (arg === "--remote") {
74+
const value = args[i + 1];
75+
if (!value || value.startsWith("--")) {
76+
logger.error("--remote requires a remote name (e.g., --remote upstream)");
77+
throw new Error("--remote requires a remote name");
78+
}
79+
result.remoteName = value;
80+
i++; // Skip value
5181
}
5282
}
5383

src/discovery/auto.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/**
2+
* Auto-discovery orchestrator
3+
*
4+
* Automatically detects GitLab configuration from the current git repository:
5+
* 1. Parses git remote to get host and project path
6+
* 2. Matches host to user-defined profile (if available)
7+
* 3. Loads project-level configs from .gitlab-mcp/ (if present)
8+
* 4. Sets default context (namespace/project)
9+
*/
10+
11+
import { parseGitRemote, GitRemoteInfo, listGitRemotes } from "./git-remote";
12+
import { findProfileByHost, ProfileMatchResult } from "./profile-matcher";
13+
import { findProjectConfig, ProjectConfig, loadAndApplyProfile } from "../profiles";
14+
import { logger } from "../logger";
15+
import { extractNamespaceFromPath } from "../utils/namespace";
16+
17+
// ============================================================================
18+
// Types
19+
// ============================================================================
20+
21+
export interface AutoDiscoveryOptions {
22+
/** Path to repository (default: current directory) */
23+
repoPath?: string;
24+
/** Remote name to use (default: origin) */
25+
remoteName?: string;
26+
/** Skip project config loading */
27+
noProjectConfig?: boolean;
28+
/** Dry run - don't apply any changes */
29+
dryRun?: boolean;
30+
}
31+
32+
export interface AutoDiscoveryResult {
33+
/** Detected GitLab host */
34+
host: string;
35+
/** Detected project path (group/project) */
36+
projectPath: string;
37+
/** Git remote info */
38+
remote: GitRemoteInfo;
39+
/** Matched user profile (if any) */
40+
matchedProfile: ProfileMatchResult | null;
41+
/** Project configuration (if found) */
42+
projectConfig: ProjectConfig | null;
43+
/** Computed GitLab API URL */
44+
apiUrl: string;
45+
/** Whether profile was applied */
46+
profileApplied: boolean;
47+
/** Whether project config was applied */
48+
projectConfigApplied: boolean;
49+
/** All available remotes (for multi-remote scenarios) */
50+
availableRemotes: GitRemoteInfo[];
51+
}
52+
53+
// ============================================================================
54+
// Discovery Logic
55+
// ============================================================================
56+
57+
/**
58+
* Auto-discover GitLab configuration from current repository
59+
*
60+
* @param options Discovery options
61+
* @returns Discovery result or null if not in a git repo
62+
*/
63+
export async function autoDiscover(
64+
options: AutoDiscoveryOptions = {}
65+
): Promise<AutoDiscoveryResult | null> {
66+
const repoPath = options.repoPath ?? process.cwd();
67+
68+
logger.info({ path: repoPath }, "Starting auto-discovery");
69+
70+
// 1. Parse git remote
71+
const remote = await parseGitRemote({
72+
repoPath,
73+
remoteName: options.remoteName,
74+
});
75+
76+
if (!remote) {
77+
logger.warn({ path: repoPath }, "Auto-discovery: No git remote found");
78+
return null;
79+
}
80+
81+
logger.info(
82+
{
83+
host: remote.host,
84+
projectPath: remote.projectPath,
85+
remote: remote.remoteName,
86+
},
87+
"Detected git remote"
88+
);
89+
90+
// Get all available remotes for info
91+
const availableRemotes = await listGitRemotes(repoPath);
92+
93+
// 2. Match host to user profile
94+
const matchedProfile = await findProfileByHost(remote.host);
95+
96+
if (matchedProfile) {
97+
logger.info(
98+
{
99+
profile: matchedProfile.profileName,
100+
matchType: matchedProfile.matchType,
101+
},
102+
"Matched host to user profile"
103+
);
104+
} else {
105+
logger.debug({ host: remote.host }, "No matching user profile found");
106+
}
107+
108+
// 3. Load project configs (unless disabled)
109+
let projectConfig: ProjectConfig | null = null;
110+
if (!options.noProjectConfig) {
111+
projectConfig = await findProjectConfig(repoPath);
112+
if (projectConfig) {
113+
logger.info({ path: projectConfig.configPath }, "Found project configuration");
114+
}
115+
}
116+
117+
// Compute API URL
118+
const apiUrl = `https://${remote.host}`;
119+
120+
// Build result
121+
const result: AutoDiscoveryResult = {
122+
host: remote.host,
123+
projectPath: remote.projectPath,
124+
remote,
125+
matchedProfile,
126+
projectConfig,
127+
apiUrl,
128+
profileApplied: false,
129+
projectConfigApplied: false,
130+
availableRemotes,
131+
};
132+
133+
// 4. Apply configuration (unless dry run)
134+
if (!options.dryRun) {
135+
// Apply profile if matched
136+
if (matchedProfile) {
137+
try {
138+
await loadAndApplyProfile(matchedProfile.profileName);
139+
result.profileApplied = true;
140+
logger.info({ profile: matchedProfile.profileName }, "Applied matched profile");
141+
} catch (error) {
142+
const message = error instanceof Error ? error.message : String(error);
143+
logger.error({ error: message }, "Failed to apply matched profile");
144+
}
145+
} else {
146+
// Set API URL from discovered host if no profile matched
147+
if (!process.env.GITLAB_API_URL) {
148+
process.env.GITLAB_API_URL = apiUrl;
149+
logger.info({ apiUrl }, "Set GITLAB_API_URL from discovered host");
150+
}
151+
}
152+
153+
// Apply project config
154+
if (projectConfig) {
155+
result.projectConfigApplied = true;
156+
// Project config application is logged but not enforced yet
157+
// See: https://github.com/structured-world/gitlab-mcp/issues/61
158+
logger.debug({ config: projectConfig }, "Project config loaded (enforcement pending)");
159+
}
160+
161+
// Set default context
162+
setDefaultContext(remote.projectPath);
163+
}
164+
165+
return result;
166+
}
167+
168+
/**
169+
* Set default project/namespace context from discovered project path
170+
*/
171+
function setDefaultContext(projectPath: string): void {
172+
// Set as environment variables for tools to use
173+
if (!process.env.GITLAB_DEFAULT_PROJECT) {
174+
process.env.GITLAB_DEFAULT_PROJECT = projectPath;
175+
logger.debug({ project: projectPath }, "Set default project context");
176+
}
177+
178+
if (!process.env.GITLAB_DEFAULT_NAMESPACE) {
179+
// Use shared utility to extract namespace
180+
const namespace = extractNamespaceFromPath(projectPath);
181+
if (namespace) {
182+
process.env.GITLAB_DEFAULT_NAMESPACE = namespace;
183+
logger.debug({ namespace }, "Set default namespace context");
184+
}
185+
}
186+
}
187+
188+
// ============================================================================
189+
// Display Functions
190+
// ============================================================================
191+
192+
/**
193+
* Format auto-discovery result for display (dry-run mode)
194+
*/
195+
export function formatDiscoveryResult(result: AutoDiscoveryResult): string {
196+
const lines: string[] = [];
197+
198+
lines.push("Auto-discovery Results");
199+
lines.push("======================");
200+
lines.push("");
201+
202+
// Git Remote
203+
lines.push("Git Remote:");
204+
lines.push(` Remote: ${result.remote.remoteName}`);
205+
lines.push(` Host: ${result.host}`);
206+
lines.push(` Project: ${result.projectPath}`);
207+
lines.push(` Protocol: ${result.remote.protocol}`);
208+
lines.push(` URL: ${result.remote.url}`);
209+
lines.push("");
210+
211+
// Multiple remotes warning
212+
if (result.availableRemotes.length > 1) {
213+
lines.push("Available Remotes:");
214+
for (const remote of result.availableRemotes) {
215+
const selected = remote.remoteName === result.remote.remoteName ? " (selected)" : "";
216+
lines.push(` ${remote.remoteName}: ${remote.host}/${remote.projectPath}${selected}`);
217+
}
218+
lines.push("");
219+
}
220+
221+
// Profile Match
222+
lines.push("Profile Match:");
223+
if (result.matchedProfile) {
224+
lines.push(` Profile: ${result.matchedProfile.profileName}`);
225+
lines.push(` Match Type: ${result.matchedProfile.matchType}`);
226+
if (result.matchedProfile.profile.authType) {
227+
lines.push(` Auth: ${result.matchedProfile.profile.authType}`);
228+
}
229+
if (result.matchedProfile.profile.readOnly) {
230+
lines.push(` Mode: read-only`);
231+
}
232+
} else {
233+
lines.push(` No matching profile found`);
234+
lines.push(` Will use: ${result.apiUrl} (from discovered host)`);
235+
lines.push(` Auth: GITLAB_TOKEN environment variable required`);
236+
}
237+
lines.push("");
238+
239+
// Project Config
240+
lines.push("Project Configuration:");
241+
if (result.projectConfig) {
242+
lines.push(` Path: ${result.projectConfig.configPath}`);
243+
if (result.projectConfig.preset) {
244+
lines.push(` Preset: ${result.projectConfig.preset.description ?? "custom restrictions"}`);
245+
if (result.projectConfig.preset.scope) {
246+
const scope = result.projectConfig.preset.scope;
247+
if (scope.project) {
248+
lines.push(` Scope: project "${scope.project}"`);
249+
} else if (scope.namespace) {
250+
lines.push(` Scope: namespace "${scope.namespace}/*"`);
251+
} else if (scope.projects) {
252+
lines.push(` Scope: ${scope.projects.length} projects`);
253+
}
254+
}
255+
if (result.projectConfig.preset.read_only) {
256+
lines.push(` Mode: read-only`);
257+
}
258+
}
259+
if (result.projectConfig.profile) {
260+
lines.push(
261+
` Profile: ${result.projectConfig.profile.description ?? "custom tool selection"}`
262+
);
263+
if (result.projectConfig.profile.extends) {
264+
lines.push(` Extends: ${result.projectConfig.profile.extends}`);
265+
}
266+
}
267+
} else {
268+
lines.push(` No .gitlab-mcp/ directory found`);
269+
}
270+
lines.push("");
271+
272+
// Default Context
273+
lines.push("Default Context:");
274+
lines.push(` Project: ${result.projectPath}`);
275+
const namespace = extractNamespaceFromPath(result.projectPath) ?? result.projectPath;
276+
lines.push(` Namespace: ${namespace}`);
277+
278+
return lines.join("\n");
279+
}

0 commit comments

Comments
 (0)