gitlab-mcp-agent-server 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # gitlab-mcp-agent-server
2
+
3
+ MCP server for GitLab integration (TypeScript + Node.js).
4
+
5
+ Полный пользовательский сценарий подключения к ИИ-агенту:
6
+ - `docs/USER_GUIDE.md`
7
+
8
+ ## Run with npx
9
+
10
+ ```bash
11
+ npx -y gitlab-mcp-agent-server
12
+ ```
13
+
14
+ Для конечного пользователя обычно достаточно:
15
+ 1. Зарегистрировать GitLab OAuth application.
16
+ 2. Передать в MCP-конфиг `GITLAB_OAUTH_CLIENT_ID` и `GITLAB_OAUTH_CLIENT_SECRET`.
17
+
18
+ Остальное работает по дефолту:
19
+ - OAuth auto-login при отсутствии токена.
20
+ - token store в `~/.config/gitlab-mcp/token.json`.
21
+ - auto-refresh access token.
22
+ - автоопределение проекта из git remote текущего `cwd`.
23
+
24
+ ## Local setup (development)
25
+
26
+ ```bash
27
+ npm install
28
+ cp .env.example .env
29
+ npm run dev
30
+ ```
31
+
32
+ ## Build and run
33
+
34
+ ```bash
35
+ npm run build
36
+ npm start
37
+ ```
38
+
39
+ ## Quality checks
40
+
41
+ ```bash
42
+ npm run lint
43
+ npm run test
44
+ npm run typecheck
45
+ ```
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ require('../build/src/index.js');
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IssueWorkflowPolicy = void 0;
4
+ const errors_1 = require("../../shared/errors");
5
+ class IssueWorkflowPolicy {
6
+ config;
7
+ constructor(config) {
8
+ this.config = config;
9
+ }
10
+ assertEnabled() {
11
+ if (!this.config.issueWorkflow.enabled) {
12
+ throw new errors_1.PolicyError('Issue workflow is disabled by configuration.');
13
+ }
14
+ }
15
+ assertActionAllowed(action) {
16
+ this.assertEnabled();
17
+ if (action === 'create' && !this.config.issueWorkflow.allowCreate) {
18
+ throw new errors_1.PolicyError('Issue creation is disabled by configuration.');
19
+ }
20
+ if (action === 'close' && !this.config.issueWorkflow.allowClose) {
21
+ throw new errors_1.PolicyError('Issue closing is disabled by configuration.');
22
+ }
23
+ if (action === 'label_update' && !this.config.issueWorkflow.allowLabelUpdate) {
24
+ throw new errors_1.PolicyError('Issue label updates are disabled by configuration.');
25
+ }
26
+ }
27
+ assertLabelsAllowed(labels) {
28
+ const allowed = this.config.issueWorkflow.allowedLabels;
29
+ if (allowed.length === 0) {
30
+ return;
31
+ }
32
+ const invalid = labels.filter((label) => !allowed.includes(label));
33
+ if (invalid.length > 0) {
34
+ throw new errors_1.PolicyError(`Labels are not allowed by policy: ${invalid.join(', ')}. Allowed: ${allowed.join(', ')}.`);
35
+ }
36
+ }
37
+ }
38
+ exports.IssueWorkflowPolicy = IssueWorkflowPolicy;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProjectResolver = void 0;
4
+ const errors_1 = require("../../shared/errors");
5
+ class ProjectResolver {
6
+ config;
7
+ constructor(config) {
8
+ this.config = config;
9
+ }
10
+ resolveProject(project) {
11
+ if (project !== undefined && project !== null && String(project).trim() !== '') {
12
+ return project;
13
+ }
14
+ if (this.config.gitlab.defaultProject) {
15
+ return this.config.gitlab.defaultProject;
16
+ }
17
+ if (this.config.gitlab.autoResolveProjectFromGit && this.config.gitlab.autoDetectedProject) {
18
+ return this.config.gitlab.autoDetectedProject;
19
+ }
20
+ throw new errors_1.ConfigurationError('Project is not resolved. Provide `project` in tool input or set GITLAB_DEFAULT_PROJECT.');
21
+ }
22
+ }
23
+ exports.ProjectResolver = ProjectResolver;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CloseIssueUseCase = void 0;
4
+ class CloseIssueUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ return this.gitlabApi.closeIssue(input);
11
+ }
12
+ }
13
+ exports.CloseIssueUseCase = CloseIssueUseCase;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CreateIssueUseCase = void 0;
4
+ class CreateIssueUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ return this.gitlabApi.createIssue(input);
11
+ }
12
+ }
13
+ exports.CreateIssueUseCase = CreateIssueUseCase;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnsureLabelsUseCase = void 0;
4
+ class EnsureLabelsUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ const existingLabels = await this.gitlabApi.listLabels({
11
+ project: input.project
12
+ });
13
+ const existingByName = new Map(existingLabels.map((label) => [label.name, label]));
14
+ const created = [];
15
+ const existing = [];
16
+ for (const item of input.labels) {
17
+ const alreadyExists = existingByName.get(item.name);
18
+ if (alreadyExists) {
19
+ existing.push(alreadyExists);
20
+ continue;
21
+ }
22
+ const payload = {
23
+ project: input.project,
24
+ name: item.name,
25
+ color: item.color,
26
+ description: item.description
27
+ };
28
+ const createdLabel = await this.gitlabApi.createLabel(payload);
29
+ created.push(createdLabel);
30
+ }
31
+ return { created, existing };
32
+ }
33
+ }
34
+ exports.EnsureLabelsUseCase = EnsureLabelsUseCase;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GetIssueUseCase = void 0;
4
+ class GetIssueUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ return this.gitlabApi.getIssue(input);
11
+ }
12
+ }
13
+ exports.GetIssueUseCase = GetIssueUseCase;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HealthCheckUseCase = void 0;
4
+ class HealthCheckUseCase {
5
+ execute(input) {
6
+ return {
7
+ status: 'ok',
8
+ echo: input.ping ?? 'pong'
9
+ };
10
+ }
11
+ }
12
+ exports.HealthCheckUseCase = HealthCheckUseCase;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListIssuesUseCase = void 0;
4
+ class ListIssuesUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ return this.gitlabApi.listIssues(input);
11
+ }
12
+ }
13
+ exports.ListIssuesUseCase = ListIssuesUseCase;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListLabelsUseCase = void 0;
4
+ class ListLabelsUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ return this.gitlabApi.listLabels(input);
11
+ }
12
+ }
13
+ exports.ListLabelsUseCase = ListLabelsUseCase;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListProjectsUseCase = void 0;
4
+ class ListProjectsUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute() {
10
+ return this.gitlabApi.listProjects();
11
+ }
12
+ }
13
+ exports.ListProjectsUseCase = ListProjectsUseCase;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UpdateIssueLabelsUseCase = void 0;
4
+ class UpdateIssueLabelsUseCase {
5
+ gitlabApi;
6
+ constructor(gitlabApi) {
7
+ this.gitlabApi = gitlabApi;
8
+ }
9
+ async execute(input) {
10
+ const current = await this.gitlabApi.getIssue({
11
+ project: input.project,
12
+ issueIid: input.issueIid
13
+ });
14
+ let nextLabels = [...current.labels];
15
+ if (input.mode === 'replace') {
16
+ nextLabels = [...input.labels];
17
+ }
18
+ if (input.mode === 'add') {
19
+ nextLabels = mergeLabels(nextLabels, input.labels);
20
+ }
21
+ if (input.mode === 'remove') {
22
+ nextLabels = nextLabels.filter((label) => !input.labels.includes(label));
23
+ }
24
+ if (input.autoRemovePreviousStateLabels && input.stateLabels && input.mode !== 'remove') {
25
+ nextLabels = removeOtherStateLabels(nextLabels, input.labels, input.stateLabels);
26
+ }
27
+ return this.gitlabApi.updateIssueLabels({
28
+ project: input.project,
29
+ issueIid: input.issueIid,
30
+ labels: nextLabels
31
+ });
32
+ }
33
+ }
34
+ exports.UpdateIssueLabelsUseCase = UpdateIssueLabelsUseCase;
35
+ function mergeLabels(existing, adding) {
36
+ return Array.from(new Set([...existing, ...adding]));
37
+ }
38
+ function removeOtherStateLabels(labels, targetLabels, stateLabels) {
39
+ const targetSet = new Set(targetLabels);
40
+ const stateSet = new Set(stateLabels);
41
+ return labels.filter((label) => {
42
+ if (!stateSet.has(label)) {
43
+ return true;
44
+ }
45
+ return targetSet.has(label);
46
+ });
47
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
4
+ const create_mcp_server_1 = require("./interface/mcp/create-mcp-server");
5
+ async function main() {
6
+ const server = (0, create_mcp_server_1.createMcpServer)();
7
+ const transport = new stdio_js_1.StdioServerTransport();
8
+ await server.connect(transport);
9
+ console.error('gitlab-mcp-server is running via stdio transport');
10
+ }
11
+ main().catch((error) => {
12
+ console.error('Fatal error in main():', error);
13
+ process.exit(1);
14
+ });
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GitLabOAuthManager = void 0;
4
+ const node_http_1 = require("node:http");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_child_process_2 = require("node:child_process");
8
+ const errors_1 = require("../../shared/errors");
9
+ const oauth_token_store_1 = require("./oauth-token-store");
10
+ class GitLabOAuthManager {
11
+ options;
12
+ tokenStore;
13
+ oauthBaseUrl;
14
+ constructor(options) {
15
+ this.options = options;
16
+ this.tokenStore = new oauth_token_store_1.OAuthTokenStore(options.tokenStorePath);
17
+ this.oauthBaseUrl = new URL(options.apiUrl).origin;
18
+ }
19
+ async getAccessToken() {
20
+ const stored = this.tokenStore.read();
21
+ if (stored && !isExpiringSoon(stored.expiresAt)) {
22
+ return stored.accessToken;
23
+ }
24
+ if (stored?.refreshToken) {
25
+ const refreshed = await this.refreshToken(stored.refreshToken);
26
+ this.tokenStore.write(refreshed);
27
+ return refreshed.accessToken;
28
+ }
29
+ if (this.options.bootstrapAccessToken) {
30
+ return this.options.bootstrapAccessToken;
31
+ }
32
+ if (!this.options.autoLogin) {
33
+ throw new errors_1.ConfigurationError('OAuth token is missing. Enable GITLAB_OAUTH_AUTO_LOGIN or provide GITLAB_OAUTH_ACCESS_TOKEN.');
34
+ }
35
+ const interactiveToken = await this.loginInteractively();
36
+ this.tokenStore.write(interactiveToken);
37
+ return interactiveToken.accessToken;
38
+ }
39
+ async refreshToken(refreshToken) {
40
+ this.assertOAuthClientCredentials();
41
+ this.assertRedirectUri();
42
+ const response = await fetch(`${this.oauthBaseUrl}/oauth/token`, {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/x-www-form-urlencoded'
46
+ },
47
+ body: toFormUrlEncoded({
48
+ client_id: this.options.clientId,
49
+ client_secret: this.options.clientSecret,
50
+ grant_type: 'refresh_token',
51
+ refresh_token: refreshToken,
52
+ redirect_uri: this.options.redirectUri
53
+ })
54
+ });
55
+ if (!response.ok) {
56
+ const body = await response.text();
57
+ throw new Error(`Failed to refresh OAuth token: ${response.status} ${body}`);
58
+ }
59
+ const payload = (await response.json());
60
+ return mapTokenResponse(payload);
61
+ }
62
+ async loginInteractively() {
63
+ this.assertOAuthClientCredentials();
64
+ this.assertRedirectUri();
65
+ const redirect = new URL(this.options.redirectUri);
66
+ const state = (0, node_crypto_1.randomBytes)(16).toString('hex');
67
+ const code = await new Promise((resolve, reject) => {
68
+ const server = (0, node_http_1.createServer)((req, res) => {
69
+ if (!req.url) {
70
+ return;
71
+ }
72
+ const url = new URL(req.url, `${redirect.protocol}//${redirect.host}`);
73
+ if (url.pathname !== redirect.pathname) {
74
+ res.statusCode = 404;
75
+ res.end('Not found');
76
+ return;
77
+ }
78
+ const responseState = url.searchParams.get('state');
79
+ const error = url.searchParams.get('error');
80
+ const authCode = url.searchParams.get('code');
81
+ if (error) {
82
+ res.statusCode = 400;
83
+ res.end('Authorization failed. You can close this tab.');
84
+ server.close();
85
+ reject(new Error(`OAuth authorization failed: ${error}`));
86
+ return;
87
+ }
88
+ if (!authCode || responseState !== state) {
89
+ res.statusCode = 400;
90
+ res.end('Invalid callback. You can close this tab.');
91
+ server.close();
92
+ reject(new Error('Invalid OAuth callback: code/state mismatch.'));
93
+ return;
94
+ }
95
+ res.statusCode = 200;
96
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
97
+ res.end('<html><body><h3>Authorization completed. You can close this tab.</h3></body></html>');
98
+ server.close();
99
+ resolve(authCode);
100
+ });
101
+ server.listen(resolvePort(redirect), redirect.hostname, () => {
102
+ const authorizeUrl = new URL(`${this.oauthBaseUrl}/oauth/authorize`);
103
+ authorizeUrl.searchParams.set('client_id', this.options.clientId);
104
+ authorizeUrl.searchParams.set('redirect_uri', this.options.redirectUri);
105
+ authorizeUrl.searchParams.set('response_type', 'code');
106
+ authorizeUrl.searchParams.set('scope', this.options.scopes.join(' '));
107
+ authorizeUrl.searchParams.set('state', state);
108
+ const authorizeUrlText = authorizeUrl.toString();
109
+ const opened = this.options.openBrowser && openInBrowser(authorizeUrlText);
110
+ if (!opened) {
111
+ console.error('Open this URL to authorize GitLab access:');
112
+ console.error(authorizeUrlText);
113
+ }
114
+ });
115
+ });
116
+ const tokenResponse = await fetch(`${this.oauthBaseUrl}/oauth/token`, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/x-www-form-urlencoded'
120
+ },
121
+ body: toFormUrlEncoded({
122
+ client_id: this.options.clientId,
123
+ client_secret: this.options.clientSecret,
124
+ code,
125
+ grant_type: 'authorization_code',
126
+ redirect_uri: this.options.redirectUri
127
+ })
128
+ });
129
+ if (!tokenResponse.ok) {
130
+ const body = await tokenResponse.text();
131
+ throw new Error(`Failed to exchange OAuth code: ${tokenResponse.status} ${body}`);
132
+ }
133
+ const payload = (await tokenResponse.json());
134
+ return mapTokenResponse(payload);
135
+ }
136
+ assertOAuthClientCredentials() {
137
+ if (!this.options.clientId || !this.options.clientSecret) {
138
+ throw new errors_1.ConfigurationError('OAuth client is not configured. Set GITLAB_OAUTH_CLIENT_ID and GITLAB_OAUTH_CLIENT_SECRET.');
139
+ }
140
+ }
141
+ assertRedirectUri() {
142
+ if (!this.options.redirectUri) {
143
+ throw new errors_1.ConfigurationError('GITLAB_OAUTH_REDIRECT_URI is required for OAuth flow.');
144
+ }
145
+ }
146
+ }
147
+ exports.GitLabOAuthManager = GitLabOAuthManager;
148
+ function mapTokenResponse(payload) {
149
+ const expiresAt = resolveExpiresAt(payload);
150
+ return {
151
+ accessToken: payload.access_token,
152
+ refreshToken: payload.refresh_token,
153
+ expiresAt,
154
+ scope: payload.scope,
155
+ tokenType: payload.token_type
156
+ };
157
+ }
158
+ function resolveExpiresAt(payload) {
159
+ const createdAtMs = payload.created_at ? payload.created_at * 1000 : Date.now();
160
+ const expiresInSec = payload.expires_in ?? 3600;
161
+ return createdAtMs + expiresInSec * 1000;
162
+ }
163
+ function isExpiringSoon(expiresAt) {
164
+ const marginMs = 60 * 1000;
165
+ return Date.now() + marginMs >= expiresAt;
166
+ }
167
+ function toFormUrlEncoded(data) {
168
+ const params = new URLSearchParams();
169
+ for (const [key, value] of Object.entries(data)) {
170
+ if (value !== undefined) {
171
+ params.set(key, value);
172
+ }
173
+ }
174
+ return params.toString();
175
+ }
176
+ function openInBrowser(url) {
177
+ const platform = process.platform;
178
+ if (!hasOpenCommand(platform)) {
179
+ return false;
180
+ }
181
+ const command = platform === 'darwin'
182
+ ? `open "${url}"`
183
+ : platform === 'win32'
184
+ ? `start "" "${url}"`
185
+ : `xdg-open "${url}"`;
186
+ (0, node_child_process_1.exec)(command, () => { });
187
+ return true;
188
+ }
189
+ function resolvePort(url) {
190
+ if (url.port) {
191
+ return Number(url.port);
192
+ }
193
+ return url.protocol === 'https:' ? 443 : 80;
194
+ }
195
+ function hasOpenCommand(platform) {
196
+ try {
197
+ if (platform === 'darwin') {
198
+ (0, node_child_process_2.execSync)('command -v open', { stdio: ['ignore', 'ignore', 'ignore'] });
199
+ return true;
200
+ }
201
+ if (platform === 'win32') {
202
+ return true;
203
+ }
204
+ (0, node_child_process_2.execSync)('command -v xdg-open', { stdio: ['ignore', 'ignore', 'ignore'] });
205
+ return true;
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OAuthTokenStore = void 0;
4
+ const node_fs_1 = require("node:fs");
5
+ class OAuthTokenStore {
6
+ filePath;
7
+ constructor(filePath) {
8
+ this.filePath = filePath;
9
+ }
10
+ read() {
11
+ if (!(0, node_fs_1.existsSync)(this.filePath)) {
12
+ return undefined;
13
+ }
14
+ const raw = (0, node_fs_1.readFileSync)(this.filePath, 'utf8');
15
+ const parsed = JSON.parse(raw);
16
+ if (!parsed.accessToken || !parsed.expiresAt) {
17
+ return undefined;
18
+ }
19
+ return parsed;
20
+ }
21
+ write(token) {
22
+ (0, node_fs_1.writeFileSync)(this.filePath, JSON.stringify(token, null, 2), 'utf8');
23
+ (0, node_fs_1.chmodSync)(this.filePath, 0o600);
24
+ }
25
+ }
26
+ exports.OAuthTokenStore = OAuthTokenStore;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StaticTokenProvider = void 0;
4
+ class StaticTokenProvider {
5
+ token;
6
+ constructor(token) {
7
+ this.token = token;
8
+ }
9
+ async getAccessToken() {
10
+ if (!this.token) {
11
+ throw new Error('Static token is not configured.');
12
+ }
13
+ return this.token;
14
+ }
15
+ }
16
+ exports.StaticTokenProvider = StaticTokenProvider;