@jswk1/gitlab-mcp 0.0.0-stage → 1.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 jswk
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 CHANGED
@@ -1,3 +1,213 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # GitLab read-only MCP server
2
+
3
+ Give your AI assistant GitLab issues, merge requests, discussions and attachments from a self-managed instance. The server reads GitLab; your assistant uses its own tools to inspect and edit your local repository.
4
+
5
+ ## Setup
6
+
7
+ - **GitLab 17.4+** for attachment downloads. Older versions are untested.
8
+ - **A personal access token with `read_api` scope**, belonging to the developer using the assistant. The token owner's GitLab permissions determine access, including confidential issues.
9
+ - **Node.js 20.20+** for npm/local setup, or **Docker** for container setup.
10
+
11
+ Choose one option below and add its configuration to your MCP client's settings. All options use **stdio**: the client starts the server and communicates over stdin/stdout. Run one server process per developer/client; there is no HTTP endpoint.
12
+
13
+ ### Option 1: npm package
14
+
15
+ Configure your MCP client to run the npm package:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "gitlab": {
21
+ "command": "npx",
22
+ "args": ["--yes", "--package=@jswk1/gitlab-mcp@1.1.0", "gitlab-mcp"],
23
+ "env": {
24
+ "GITLAB_URL": "https://gitlab.example.internal",
25
+ "GITLAB_TOKEN": "YOUR_PERSONAL_READ_API_TOKEN"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ The first run downloads the package. Keep a pinned version for reproducible installs. If the package is unavailable, build locally or use Docker.
33
+
34
+ ### Option 2: local build over stdio
35
+
36
+ From the repository root:
37
+
38
+ ```sh
39
+ npm ci
40
+ npm run build
41
+ ```
42
+
43
+ Point your MCP client at the compiled entry point using an absolute path:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "gitlab": {
49
+ "command": "node",
50
+ "args": ["/absolute/path/to/gitlab-mcp/dist/index.js"],
51
+ "env": {
52
+ "GITLAB_URL": "https://gitlab.example.internal",
53
+ "GITLAB_TOKEN": "YOUR_PERSONAL_READ_API_TOKEN"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ To launch manually, copy `.env.example` to `.env`, fill in your credentials, then run `node --env-file=.env dist/index.js`. The process waits for MCP messages; it is not an interactive terminal application.
61
+
62
+ ### Option 3: Docker
63
+
64
+ From the repository root:
65
+
66
+ ```sh
67
+ npm run docker:build
68
+ cp .env.example .env
69
+ ```
70
+
71
+ Fill in `GITLAB_URL` and `GITLAB_TOKEN` in `.env`, then configure your MCP client:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "gitlab": {
77
+ "command": "docker",
78
+ "args": [
79
+ "run", "--rm", "-i",
80
+ "--env-file", "/absolute/path/to/gitlab-mcp/.env",
81
+ "gitlab-readonly-mcp"
82
+ ]
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Keep `-i`; do not add `-t` or `-d`. No ports or repository mounts are needed. The image runs as a non-root user and receives credentials at runtime.
89
+
90
+ GitLab must be reachable from inside the container. On Docker Desktop, use `host.docker.internal` to reach GitLab on your host. On Linux Docker Engine, also add `--add-host=host.docker.internal:host-gateway` to the Docker arguments when using that hostname.
91
+
92
+ After source updates, rebuild the image and restart your client's MCP connection to create a new container.
93
+
94
+ ## Workflows
95
+
96
+ Ask your assistant to work on an issue or MR in your local repository. It can start with `gitlab_get_workflow`, passing `project`, `iid`, `target` (`issue` or `merge_request`), optional `profile`, optional `goal` to specify scope, and optional `mode` for developer/documentalist issue resolution.
97
+
98
+ | Profile | Issue | Merge request |
99
+ | --- | --- | --- |
100
+ | `developer` (default) | Investigate and guide the user through a fix/feature, or implement automatically when selected | Review changed code for bugs, regressions and improvements |
101
+ | `tester` | Inspect the latest related implementation and prepare prioritized manual test cases | Prepare a manual test plan for changed behavior |
102
+ | `documentalist` | Guide documentation implementation, or edit automatically when selected, using merged/current implementation | Update local Markdown docs to match the implementation |
103
+ | `business_analyst` (business analyst/designer) | Report on client requirements and issue status; reconcile external sheets | Summarize business/design impact for client reporting |
104
+
105
+ Profiles apply per task and share the same tools and permissions. The workflow tool returns guidance; the assistant gathers evidence and performs local work. Tester workflows produce plans, not test execution. Implementation and documentation edits require a client with local repository access and editing tools.
106
+
107
+ Developer and documentalist issue resolution offers **guided mode (recommended)** and **fully automated mode**. If the user asks to resolve, fix, implement or update an issue without choosing a mode, the assistant asks which they prefer and waits before making changes. A plain implementation request does not select automation. Explicit requests such as “automatically implement this issue” skip the question, as do explicit guided requests or an existing mode choice for the same task. Analysis-only, plan-only and draft-only requests keep their scope without a mode question.
108
+
109
+ In guided mode, the user edits the code, tests or documentation. The assistant investigates, outlines the approach, then gives one manageable step at a time with relevant files, what to change, why, pitfalls and checks. It waits for the user's changes/results, reviews them and adapts the next step. In automated mode, the assistant performs the implementation and checks. Neither mode authorizes commits, pushes or GitLab writes.
110
+
111
+ Clients can pass `mode: "guided"` or `mode: "automated"` to `gitlab_get_workflow`, `implement_issue`, `investigate_issue` or `update_documentation`. Omitting it (or passing `"ask"`) delegates the mode question and interpretation of explicit user intent to the host assistant. Workflow calls do not persist choices; the host carries the user's selection forward for the same task. Mode selection applies to developer/documentalist issue implementation; MR reviews, tester and reporting workflows retain their existing behavior.
112
+
113
+ For aggregate reporting, select `profile: "business_analyst"`, `target: "report"`, `project` and a `goal`; omit `iid`. This mode does not require a source checkout. The assistant uses host file tools to generate actual CSV files with the requested columns (or returns CSV text if file tools are unavailable). External sheets require a user-provided file or an authorized host connector; this server does not connect to or update sheets itself.
114
+
115
+ Client attribution must use an explicit label or supplied mapping. Reports follow all issue pages and filter `closed_at` locally with inclusive start and exclusive end dates in the agreed timezone. Current closed issues do not describe historical closure events for reopened issues. Milestone membership and closure dates are separate filters. Reports include source links, exact filters, retrieval time, totals and coverage limits. Client-facing exports omit confidential/internal content unless authorized, and CSV text cells are protected against spreadsheet formulas. Reconciliation preserves stable issue and external row identifiers and reports duplicates, missing rows and conflicts.
116
+
117
+ Example requests:
118
+
119
+ - “Guide me through fixing issue #123. I want to make the changes myself.”
120
+ - “Automatically implement issue #123, including the relevant tests.”
121
+ - “Read issue #123 in `group/project`, including comments and attachments, and fix the validation bug in this repository.”
122
+ - “Review MR !456 in `group/project`. Return findings limited to changed code, ordered by severity.”
123
+ - “Use the tester profile for issue #123 in `group/project` and prepare the critical manual test cases for the latest related branch.”
124
+ - “Use the documentalist profile for MR !456 in `group/project` and update the affected Markdown docs.”
125
+ - “Use the business analyst/designer profile: export issues closed for client X in Q3 2026 from `group/project`. Client X uses label `client::X`; use Europe/Warsaw time and include IID, title, milestone, closed date and URL.”
126
+ - “Generate a CSV of closed client X issues in milestone `Release 2.0`, then reconcile it with this client sheet and report missing or conflicting rows.”
127
+
128
+ Clients supporting MCP prompts can use these shortcuts:
129
+
130
+ | Prompt | Purpose | Arguments |
131
+ | --- | --- | --- |
132
+ | `report_issues` | Generate reports/CSVs or reconcile sheets as business analyst/designer | `project`, `goal` |
133
+ | `implement_issue` | Resolve an issue as developer in guided or automated mode | `project`, `iid`, optional `goal`, `mode` |
134
+ | `investigate_issue` | Investigate or act on an issue | `project`, `iid`, optional `profile`, `goal`, `mode` |
135
+ | `review_merge_request` | Review an MR using the selected profile | `project`, `iid`, optional `profile`, `goal` |
136
+ | `prepare_test_plan` | Plan manual tests as tester | `project`, `iid`, `target`, optional `goal` |
137
+ | `update_documentation` | Resolve documentation as documentalist; issues offer guided/automated modes | `project`, `iid`, `target`, optional `goal`, `mode` |
138
+ | `draft_documentation` | Return documentation drafts without editing files | `project`, `iid`, `target` |
139
+
140
+ ## Available tools
141
+
142
+ | Tool | Purpose |
143
+ | --- | --- |
144
+ | `gitlab_get_workflow` | Get task guidance for a profile and issue/MR or aggregate report |
145
+ | `gitlab_current_user` | Identify the token owner |
146
+ | `gitlab_list_projects` | Find accessible projects |
147
+ | `gitlab_get_project` | Read project metadata and default branch |
148
+ | `gitlab_list_branches` | Find branches and head commit metadata |
149
+ | `gitlab_get_branch` | Resolve a branch's current head SHA and commit date |
150
+ | `gitlab_compare_refs` | Compare branches or commits, including changes without an MR |
151
+ | `gitlab_list_issues` | Search issues by text, labels, state, milestone title and update lower bound |
152
+ | `gitlab_get_issue` | Read issue metadata, description and discovered uploads |
153
+ | `gitlab_issue_related_merge_requests` | Find MRs associated with an issue |
154
+ | `gitlab_get_discussions` | Read issue/MR threads, replies, review positions and uploads |
155
+ | `gitlab_read_attachment` | Reuse cached analysis or read an upload by project, secret and filename |
156
+ | `gitlab_save_attachment_summary` | Save completed host analysis in the local cache; never writes to GitLab |
157
+ | `gitlab_list_merge_requests` | Search merge requests |
158
+ | `gitlab_get_merge_request` | Read MR metadata, description, diff SHAs and pipeline summary |
159
+ | `gitlab_get_merge_request_diffs` | Read changed files and unified diffs |
160
+ | `gitlab_list_merge_request_commits` | Read MR commits |
161
+ | `gitlab_repository_tree` | Browse repository paths when unavailable locally |
162
+ | `gitlab_read_file` | Read repository text when unavailable locally |
163
+ | `gitlab_prepare_review_comment` | Prepare a general or validated inline draft without posting |
164
+
165
+ `project` accepts a numeric ID or `group/subgroup/repository` path. Issue/MR `iid` is the project-local number in its web URL. Paginated tools return `items` and `nextPage`; follow pages until `nextPage` is null for complete results. Default page size is 20, maximum 100. Ref comparisons do not paginate diffs; GitLab can omit or truncate diffs, so check `compare_timeout`, `collapsed` and `too_large` where returned.
166
+
167
+ Issue reporting filters use the [GitLab Issues API](https://docs.gitlab.com/api/issues/). `milestone` is a title; `updated_after` is an update-time lower bound, not a closure-time filter.
168
+
169
+ ## Attachment cache
170
+
171
+ Attachment downloads and analysis summaries persist locally across server restarts. The assistant first calls `gitlab_read_attachment`. If a summary exists for the requested `analysis_focus` (default `general`), the server returns that summary instead of sending the image/video back for interpretation. Otherwise it reuses cached bytes or downloads the upload once. Concurrent reads of the same upload share one download.
172
+
173
+ After actually inspecting the attachment, the assistant calls `gitlab_save_attachment_summary` with the returned `sha256`, matching `analysis_focus`, `inspected: true`, factual `summary`, inspection `method`, `coverage` and `limitations`. The server stores the host's analysis; it does not run a vision/video model itself. Summaries are bound to the downloaded content hash. Video frames or transcripts must be marked partial with explicit limitations; an unsupported download alone does not count as inspection. The workflows tell the assistant to save and reuse these summaries during subsequent resolution steps.
174
+
175
+ - `reuse_summary: false` reopens the cached content for more detailed inspection without downloading again.
176
+ - A different `analysis_focus` keeps a separate summary for a different question (up to 10 scopes per upload).
177
+ - `refresh: true` downloads a fresh copy and invalidates previous summaries, even if the bytes are unchanged.
178
+ - `output: "resource"` returns cached raw bytes for a capable host/parser instead of a summary.
179
+
180
+ The default directory is `~/.cache/gitlab-mcp/attachments`. Entries expire for reuse after seven days and the cache retains at most 256 uploads across server processes, evicting older files when new entries are saved. Each upload remains subject to `GITLAB_MAX_BYTES`. Cache files contain private attachment content and summaries, use owner-only file permissions, and never store the token. Entries are isolated by GitLab instance, token, project and upload identity; token rotation starts a separate cache. Cached reads require a live authenticated project-access check, so they cannot be used offline. This check validates project access, not whether an individual upload has since been deleted; use refresh when fresh attachment availability matters. Remove the cache directory with the server stopped to clear all stored content. If disk caching is unavailable, downloads still use a bounded in-memory cache; a failed summary persistence request returns an error.
181
+
182
+ For Docker, persist the cache by adding these arguments before the image name in the existing configuration:
183
+
184
+ ```json
185
+ "--mount", "type=volume,src=gitlab-mcp-cache,dst=/home/node/.cache/gitlab-mcp"
186
+ ```
187
+
188
+ Without a volume, Docker's `--rm` removes cached content when the container exits.
189
+
190
+ ## Configuration
191
+
192
+ | Environment variable | Default | Purpose |
193
+ | --- | --- | --- |
194
+ | `GITLAB_URL` | Required | GitLab instance root, optionally with a subpath; omit `/api/v4` |
195
+ | `GITLAB_TOKEN` | Required | Developer's personal access token with `read_api` |
196
+ | `GITLAB_USER_ID` | Optional | Expected numeric user ID; startup fails if the token belongs to another user |
197
+ | `GITLAB_TIMEOUT_MS` | `30000` | Timeout per API request and response body |
198
+ | `GITLAB_CACHE_DIR` | `~/.cache/gitlab-mcp/attachments` | Local attachment and summary cache directory |
199
+ | `GITLAB_CACHE_TTL_MS` | `604800000` (7 days) | Maximum age for reuse since download |
200
+ | `GITLAB_CACHE_MAX_ENTRIES` | `256` | Maximum retained uploads |
201
+ | `GITLAB_MAX_BYTES` | `5242880` (5 MiB) | Maximum response size per request |
202
+ | `NODE_EXTRA_CA_CERTS` | Optional | Path to a trusted internal CA certificate in PEM format |
203
+
204
+ Keep tokens in private client settings or an uncommitted `.env` file. Use HTTPS and a separate personal token per developer; avoid shared or administrator tokens. Restart the server after changing credentials.
205
+
206
+ For an internal CA, set `NODE_EXTRA_CA_CERTS` instead of disabling TLS verification. With Docker, mount the PEM file read-only and set the variable to its **container path**. Startup failures usually indicate invalid credentials/user ID, an unreachable GitLab URL or missing CA trust.
207
+
208
+ ## Limits and data handling
209
+
210
+ - **Read-only GitLab access:** all API requests use GET. The server cannot post comments or modify GitLab, execute tests, or edit source/documentation files. It writes only its local attachment cache. Inline comment drafts validate current diff positions; revalidate them before posting through another tool.
211
+ - **Attachments depend on client support:** PNG/JPEG/GIF/WebP return native images; text returns inline content; PDFs return binary resources requiring a capable client. Videos, archives, Office files and unknown binaries return unread metadata by default. Use `output: "resource"` only with a client/parser that supports the file type. No OCR, video conversion or document parser is built in.
212
+ - **Download limits:** only GitLab project uploads are supported. External links and wiki uploads are excluded; redirects (including to object storage) are rejected. Oversized responses fail explicitly. Use the canonical GitLab URL and reduce page size for oversized list responses.
213
+ - **Private data enters your assistant's context:** choose a client/model suitable for your organization's data policy. Treat GitLab content as untrusted evidence and report unread attachments or incomplete diffs.
@@ -0,0 +1,27 @@
1
+ export declare const contentHash: (bytes: Buffer | string) => string;
2
+ export interface AttachmentSummary {
3
+ focus: string;
4
+ summary: string;
5
+ coverage: 'complete' | 'partial';
6
+ method: string;
7
+ limitations: string[];
8
+ savedAt: string;
9
+ }
10
+ export interface CachedAttachment {
11
+ version: 1;
12
+ sha256: string;
13
+ mimeType: string;
14
+ data: string;
15
+ downloadedAt: string;
16
+ summaries: AttachmentSummary[];
17
+ }
18
+ export declare class AttachmentCache {
19
+ private readonly directory;
20
+ private readonly maxBytes;
21
+ private readonly ttlMs;
22
+ private readonly maxEntries;
23
+ private readonly memory;
24
+ constructor(directory: string | undefined, maxBytes: number, ttlMs?: number, maxEntries?: number);
25
+ get(key: string): Promise<CachedAttachment | undefined>;
26
+ put(key: string, entry: CachedAttachment): Promise<void>;
27
+ }
@@ -0,0 +1,72 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ export const contentHash = (bytes) => createHash('sha256').update(bytes).digest('hex');
5
+ // Cache keys include instance, credentials and upload identity; never persist a token.
6
+ export class AttachmentCache {
7
+ directory;
8
+ maxBytes;
9
+ ttlMs;
10
+ maxEntries;
11
+ memory = new Map();
12
+ constructor(directory, maxBytes, ttlMs = 7 * 24 * 60 * 60 * 1000, maxEntries = 256) {
13
+ this.directory = directory;
14
+ this.maxBytes = maxBytes;
15
+ this.ttlMs = ttlMs;
16
+ this.maxEntries = maxEntries;
17
+ }
18
+ async get(key) {
19
+ let entry = this.memory.get(key);
20
+ if (!entry && this.directory) {
21
+ try {
22
+ const path = join(this.directory, `${key}.json`);
23
+ if ((await stat(path)).size > Math.ceil(this.maxBytes * 4 / 3) + 1024 * 1024)
24
+ return undefined;
25
+ entry = JSON.parse(await readFile(path, 'utf8'));
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ if (!entry)
32
+ return undefined;
33
+ try {
34
+ const bytes = Buffer.from(entry.data, 'base64');
35
+ const age = Date.now() - Date.parse(entry.downloadedAt);
36
+ if (!Array.isArray(entry.summaries))
37
+ return undefined;
38
+ if (!entry.summaries.every(summary => summary && typeof summary.focus === 'string' && typeof summary.summary === 'string' && ['complete', 'partial'].includes(summary.coverage) && typeof summary.method === 'string' && typeof summary.savedAt === 'string' && Array.isArray(summary.limitations) && summary.limitations.every(value => typeof value === 'string')))
39
+ return undefined;
40
+ if (entry.version !== 1 || !Number.isFinite(age) || age < 0 || age >= this.ttlMs || bytes.length > this.maxBytes || contentHash(bytes) !== entry.sha256 || typeof entry.mimeType !== 'string' || !Array.isArray(entry.summaries))
41
+ return undefined;
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ return entry;
47
+ }
48
+ async put(key, entry) {
49
+ this.memory.delete(key);
50
+ this.memory.set(key, entry);
51
+ while (this.memory.size > this.maxEntries)
52
+ this.memory.delete(this.memory.keys().next().value);
53
+ if (this.directory) {
54
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
55
+ const path = join(this.directory, `${key}.json`);
56
+ const temporary = `${path}.${randomUUID()}.tmp`;
57
+ try {
58
+ await writeFile(temporary, JSON.stringify(entry), { mode: 0o600, flag: 'wx' });
59
+ await rename(temporary, path);
60
+ }
61
+ finally {
62
+ await unlink(temporary).catch(() => { });
63
+ }
64
+ // Bound on-disk retention, including entries from earlier server processes.
65
+ const files = (await readdir(this.directory)).filter(name => /^[a-f\d]{64}\.json$/.test(name));
66
+ const dated = await Promise.all(files.map(async (name) => ({ name, time: (await stat(join(this.directory, name)).catch(() => ({ mtimeMs: 0 }))).mtimeMs })));
67
+ dated.sort((a, b) => b.time - a.time);
68
+ await Promise.all(dated.slice(this.maxEntries).map(file => unlink(join(this.directory, file.name)).catch(() => { })));
69
+ }
70
+ }
71
+ }
72
+ //# sourceMappingURL=attachment-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-cache.js","sourceRoot":"","sources":["../src/attachment-cache.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export declare function uploadMimeType(bytes: Buffer, filename: string, contentType: string | null): string;
@@ -0,0 +1,28 @@
1
+ import { extname } from 'node:path';
2
+ // GitLab/proxies may serve uploads with a generic download Content-Type.
3
+ // Prefer recognizable bytes, then the filename, only for generic headers.
4
+ export function uploadMimeType(bytes, filename, contentType) {
5
+ const declared = contentType?.split(';')[0]?.trim().toLowerCase();
6
+ if (declared && !['application/octet-stream', 'binary/octet-stream', 'application/binary'].includes(declared))
7
+ return declared;
8
+ if (bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')))
9
+ return 'image/png';
10
+ if (bytes.length >= 3 && bytes.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex')))
11
+ return 'image/jpeg';
12
+ if (['GIF87a', 'GIF89a'].includes(bytes.toString('ascii', 0, 6)))
13
+ return 'image/gif';
14
+ if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP')
15
+ return 'image/webp';
16
+ if (bytes.toString('ascii', 0, 5) === '%PDF-')
17
+ return 'application/pdf';
18
+ const types = {
19
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
21
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime',
22
+ '.webm': 'video/webm', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska',
23
+ '.txt': 'text/plain', '.log': 'text/plain', '.md': 'text/markdown',
24
+ '.csv': 'text/csv', '.json': 'application/json', '.xml': 'application/xml',
25
+ };
26
+ return types[extname(filename).toLowerCase()] ?? 'application/octet-stream';
27
+ }
28
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,yEAAyE;AACzE,0EAA0E;AAC1E,MAAM,UAAU,cAAc,CAAC,KAAa,EAAE,QAAgB,EAAE,WAA0B;IACxF,MAAM,QAAQ,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAClE,IAAI,QAAQ,IAAI,CAAC,CAAC,0BAA0B,EAAE,qBAAqB,EAAE,oBAAoB,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC/H,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,WAAW,CAAC;IAC5F,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QAAE,OAAO,YAAY,CAAC;IACxG,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,WAAW,CAAC;IACrF,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,MAAM;QAAE,OAAO,YAAY,CAAC;IAC/G,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,OAAO;QAAE,OAAO,iBAAiB,CAAC;IACxE,MAAM,KAAK,GAA2B;QACpC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY;QAChE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,iBAAiB;QACrE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,iBAAiB;QACnE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,kBAAkB;QAC5E,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe;QAClE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,kBAAkB,EAAE,MAAM,EAAE,iBAAiB;KAC3E,CAAC;IACF,OAAO,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,0BAA0B,CAAC;AAC9E,CAAC"}
@@ -0,0 +1,11 @@
1
+ export interface Config {
2
+ baseUrl: string;
3
+ token: string;
4
+ expectedUserId?: number;
5
+ timeoutMs: number;
6
+ maxBytes: number;
7
+ cacheDir?: string;
8
+ cacheTtlMs?: number;
9
+ cacheMaxEntries?: number;
10
+ }
11
+ export declare function readConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ function integer(value, fallback, name) {
4
+ const parsed = value === undefined ? fallback : Number(value);
5
+ if (!Number.isSafeInteger(parsed) || parsed <= 0)
6
+ throw new Error(`${name} must be a positive integer`);
7
+ return parsed;
8
+ }
9
+ export function readConfig(env = process.env) {
10
+ if (!env.GITLAB_URL || !env.GITLAB_TOKEN?.trim())
11
+ throw new Error('GITLAB_URL and GITLAB_TOKEN are required');
12
+ const url = new URL(env.GITLAB_URL);
13
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
14
+ throw new Error('GITLAB_URL must be an HTTP(S) instance URL without credentials, query, or fragment');
15
+ }
16
+ if (/[\r\n]/.test(env.GITLAB_TOKEN))
17
+ throw new Error('Invalid GITLAB_TOKEN');
18
+ return {
19
+ baseUrl: url.href.replace(/\/$/, ''), token: env.GITLAB_TOKEN.trim(),
20
+ expectedUserId: env.GITLAB_USER_ID === undefined ? undefined : integer(env.GITLAB_USER_ID, 1, 'GITLAB_USER_ID'),
21
+ timeoutMs: integer(env.GITLAB_TIMEOUT_MS, 30000, 'GITLAB_TIMEOUT_MS'),
22
+ cacheDir: env.GITLAB_CACHE_DIR || join(homedir(), '.cache', 'gitlab-mcp', 'attachments'),
23
+ cacheTtlMs: integer(env.GITLAB_CACHE_TTL_MS, 7 * 24 * 60 * 60 * 1000, 'GITLAB_CACHE_TTL_MS'),
24
+ cacheMaxEntries: integer(env.GITLAB_CACHE_MAX_ENTRIES, 256, 'GITLAB_CACHE_MAX_ENTRIES'),
25
+ maxBytes: integer(env.GITLAB_MAX_BYTES, 5 * 1024 * 1024, 'GITLAB_MAX_BYTES'),
26
+ };
27
+ }
28
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAajC,SAAS,OAAO,CAAC,KAAyB,EAAE,QAAgB,EAAE,IAAY;IACxE,MAAM,MAAM,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9D,IAAI,CAAC,MAAM,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,6BAA6B,CAAC,CAAC;IACxG,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC7D,IAAI,CAAC,GAAG,CAAC,UAAU,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;IAC9G,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;IACpC,IAAI,CAAC,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC;QAC1G,MAAM,IAAI,KAAK,CAAC,oFAAoF,CAAC,CAAC;IACxG,CAAC;IACD,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC;IAC7E,OAAO;QACL,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,YAAY,CAAC,IAAI,EAAE;QACpE,cAAc,EAAE,GAAG,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,EAAE,gBAAgB,CAAC;QAC/G,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,KAAK,EAAE,mBAAmB,CAAC;QACrE,QAAQ,EAAE,GAAG,CAAC,gBAAgB,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,CAAC;QACxF,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,qBAAqB,CAAC;QAC5F,eAAe,EAAE,OAAO,CAAC,GAAG,CAAC,wBAAwB,EAAE,GAAG,EAAE,0BAA0B,CAAC;QACvF,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,kBAAkB,CAAC;KAC7E,CAAC;AACJ,CAAC"}
@@ -0,0 +1,42 @@
1
+ import type { Config } from './config.js';
2
+ import { type AttachmentSummary } from './attachment-cache.js';
3
+ export declare const segment: (value: string | number) => string;
4
+ export declare const projectPath: (project: string) => string;
5
+ export type Query = Record<string, string | number | boolean | undefined>;
6
+ export interface Page<T> {
7
+ items: T[];
8
+ nextPage: number | null;
9
+ }
10
+ export interface Upload {
11
+ path: string;
12
+ secret: string;
13
+ filename: string;
14
+ }
15
+ export declare function findUploads(text: string): Upload[];
16
+ export declare class GitLabError extends Error {
17
+ }
18
+ export declare class GitLabClient {
19
+ private readonly config;
20
+ private readonly fetcher;
21
+ private readonly attachmentCache;
22
+ private readonly pendingUploads;
23
+ constructor(config: Config, fetcher?: typeof fetch);
24
+ private attachmentKey;
25
+ private request;
26
+ json<T = unknown>(path: string, query?: Query): Promise<T>;
27
+ page<T = unknown>(path: string, query?: Query): Promise<Page<T>>;
28
+ identity(): Promise<{
29
+ id: number;
30
+ username: string;
31
+ name: string;
32
+ }>;
33
+ upload(project: string, secret: string, filename: string, refresh?: boolean): Promise<{
34
+ bytes: Buffer;
35
+ mimeType: string;
36
+ sha256: string;
37
+ downloadedAt: string;
38
+ summaries: AttachmentSummary[];
39
+ cached: boolean;
40
+ }>;
41
+ saveAttachmentSummary(project: string, secret: string, filename: string, sha256: string, summary: Omit<AttachmentSummary, 'savedAt'>): Promise<AttachmentSummary>;
42
+ }