@zereight/mcp-gitlab 2.0.7 → 2.0.8

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/build/schemas.js CHANGED
@@ -1758,3 +1758,159 @@ export const ExecuteGraphQLSchema = z.object({
1758
1758
  .optional()
1759
1759
  .describe("Variables object for the GraphQL query"),
1760
1760
  });
1761
+ // Release schemas
1762
+ export const GitLabReleaseAssetLinkSchema = z.object({
1763
+ id: z.number().optional(),
1764
+ name: z.string(),
1765
+ url: z.string(),
1766
+ direct_asset_path: z.string().optional(),
1767
+ link_type: z.enum(["other", "runbook", "image", "package"]).optional(),
1768
+ });
1769
+ export const GitLabReleaseAssetSourceSchema = z.object({
1770
+ format: z.string(),
1771
+ url: z.string(),
1772
+ });
1773
+ export const GitLabReleaseAssetsSchema = z.object({
1774
+ count: z.number().optional(),
1775
+ sources: z.array(GitLabReleaseAssetSourceSchema).optional(),
1776
+ links: z.array(GitLabReleaseAssetLinkSchema).optional(),
1777
+ evidence_file_path: z.string().optional(),
1778
+ });
1779
+ export const GitLabReleaseEvidenceSchema = z.object({
1780
+ sha: z.string(),
1781
+ filepath: z.string(),
1782
+ collected_at: z.string(),
1783
+ });
1784
+ export const GitLabReleaseSchema = z.object({
1785
+ tag_name: z.string(),
1786
+ name: z.string().nullable().optional(),
1787
+ description: z.string().nullable().optional(),
1788
+ description_html: z.string().nullable().optional(),
1789
+ created_at: z.string(),
1790
+ released_at: z.string().nullable().optional(),
1791
+ author: z.object({
1792
+ id: z.number(),
1793
+ name: z.string(),
1794
+ username: z.string(),
1795
+ state: z.string(),
1796
+ avatar_url: z.string().nullable().optional(),
1797
+ web_url: z.string(),
1798
+ }).optional(),
1799
+ commit: z.object({
1800
+ id: z.string(),
1801
+ short_id: z.string(),
1802
+ title: z.string(),
1803
+ created_at: z.string(),
1804
+ parent_ids: z.array(z.string()),
1805
+ message: z.string(),
1806
+ author_name: z.string(),
1807
+ author_email: z.string(),
1808
+ authored_date: z.string(),
1809
+ committer_name: z.string(),
1810
+ committer_email: z.string(),
1811
+ committed_date: z.string(),
1812
+ }).optional(),
1813
+ milestones: z.array(GitLabMilestonesSchema).optional(),
1814
+ commit_path: z.string().optional(),
1815
+ tag_path: z.string().optional(),
1816
+ assets: GitLabReleaseAssetsSchema.optional(),
1817
+ evidences: z.array(GitLabReleaseEvidenceSchema).optional(),
1818
+ _links: z.object({
1819
+ closed_issues_url: z.string().optional(),
1820
+ closed_merge_requests_url: z.string().optional(),
1821
+ edit_url: z.string().optional(),
1822
+ merged_merge_requests_url: z.string().optional(),
1823
+ opened_issues_url: z.string().optional(),
1824
+ opened_merge_requests_url: z.string().optional(),
1825
+ self: z.string().optional(),
1826
+ }).optional(),
1827
+ upcoming_release: z.boolean().optional(),
1828
+ historical_release: z.boolean().optional(),
1829
+ });
1830
+ export const ListReleasesSchema = z
1831
+ .object({
1832
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1833
+ order_by: z
1834
+ .enum(["released_at", "created_at"])
1835
+ .optional()
1836
+ .describe("The field to use as order. Either released_at (default) or created_at."),
1837
+ sort: z
1838
+ .enum(["desc", "asc"])
1839
+ .optional()
1840
+ .describe("The direction of the order. Either desc (default) for descending order or asc for ascending order."),
1841
+ include_html_description: z
1842
+ .boolean()
1843
+ .optional()
1844
+ .describe("If true, a response includes HTML rendered Markdown of the release description."),
1845
+ })
1846
+ .merge(PaginationOptionsSchema);
1847
+ export const GetReleaseSchema = z.object({
1848
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1849
+ tag_name: z.string().describe("The Git tag the release is associated with"),
1850
+ include_html_description: z
1851
+ .boolean()
1852
+ .optional()
1853
+ .describe("If true, a response includes HTML rendered Markdown of the release description."),
1854
+ });
1855
+ export const CreateReleaseSchema = z.object({
1856
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1857
+ tag_name: z.string().describe("The tag where the release is created from"),
1858
+ name: z.string().optional().describe("The release name"),
1859
+ tag_message: z.string().optional().describe("Message to use if creating a new annotated tag"),
1860
+ description: z.string().optional().describe("The description of the release. You can use Markdown."),
1861
+ ref: z
1862
+ .string()
1863
+ .optional()
1864
+ .describe("If a tag specified in tag_name doesn't exist, the release is created from ref and tagged with tag_name. It can be a commit SHA, another tag name, or a branch name."),
1865
+ milestones: z
1866
+ .array(z.string())
1867
+ .optional()
1868
+ .describe("The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones."),
1869
+ assets: z
1870
+ .object({
1871
+ links: z
1872
+ .array(z.object({
1873
+ name: z.string().describe("The name of the link. Link names must be unique within the release."),
1874
+ url: z.string().describe("The URL of the link. Link URLs must be unique within the release."),
1875
+ direct_asset_path: z.string().optional().describe("Optional path for a direct asset link."),
1876
+ link_type: z
1877
+ .enum(["other", "runbook", "image", "package"])
1878
+ .optional()
1879
+ .describe("The type of the link: other, runbook, image, package. Defaults to other."),
1880
+ }))
1881
+ .optional(),
1882
+ })
1883
+ .optional()
1884
+ .describe("An array of assets links"),
1885
+ released_at: z
1886
+ .string()
1887
+ .optional()
1888
+ .describe("Date and time for the release. Defaults to the current time. Expected in ISO 8601 format (2019-03-15T08:00:00Z). Only provide this field if creating an upcoming or historical release."),
1889
+ });
1890
+ export const UpdateReleaseSchema = z.object({
1891
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1892
+ tag_name: z.string().describe("The Git tag the release is associated with"),
1893
+ name: z.string().optional().describe("The release name"),
1894
+ description: z.string().optional().describe("The description of the release. You can use Markdown."),
1895
+ milestones: z
1896
+ .array(z.string())
1897
+ .optional()
1898
+ .describe("The title of each milestone to associate with the release. GitLab Premium customers can specify group milestones. To remove all milestones from the release, specify []."),
1899
+ released_at: z
1900
+ .string()
1901
+ .optional()
1902
+ .describe("The date when the release is/was ready. Expected in ISO 8601 format (2019-03-15T08:00:00Z)."),
1903
+ });
1904
+ export const DeleteReleaseSchema = z.object({
1905
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1906
+ tag_name: z.string().describe("The Git tag the release is associated with"),
1907
+ });
1908
+ export const CreateReleaseEvidenceSchema = z.object({
1909
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1910
+ tag_name: z.string().describe("The Git tag the release is associated with"),
1911
+ });
1912
+ export const DownloadReleaseAssetSchema = z.object({
1913
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1914
+ tag_name: z.string().describe("The Git tag the release is associated with"),
1915
+ direct_asset_path: z.string().describe("Path to the release asset file as specified when creating or updating its link"),
1916
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Custom Header MCP Client for Testing Remote Authorization
3
+ * Extends StreamableHTTPTestClient to support custom headers
4
+ */
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ /**
8
+ * MCP client with support for custom HTTP headers
9
+ * Useful for testing remote authorization scenarios
10
+ */
11
+ export class CustomHeaderClient {
12
+ client;
13
+ transport = null;
14
+ customHeaders;
15
+ timeout;
16
+ constructor(options = {}) {
17
+ // Support both old signature (headers only) and new options object
18
+ if ('headers' in options || 'timeout' in options || 'clientName' in options) {
19
+ const opts = options;
20
+ this.customHeaders = opts.headers || {};
21
+ this.timeout = opts.timeout || 30000;
22
+ this.client = new Client({
23
+ name: opts.clientName || "test-client-with-headers",
24
+ version: opts.clientVersion || "1.0.0"
25
+ });
26
+ }
27
+ else {
28
+ // Backward compatible: treat options as headers record
29
+ this.customHeaders = options;
30
+ this.timeout = 30000;
31
+ this.client = new Client({ name: "test-client-with-headers", version: "1.0.0" });
32
+ }
33
+ }
34
+ /**
35
+ * Connect to MCP server with custom headers
36
+ */
37
+ async connect(url) {
38
+ if (this.transport) {
39
+ throw new Error('Client is already connected');
40
+ }
41
+ try {
42
+ this.transport = new StreamableHTTPClientTransport(new URL(url), {
43
+ requestInit: {
44
+ headers: this.customHeaders
45
+ }
46
+ });
47
+ await this.client.connect(this.transport);
48
+ }
49
+ catch (error) {
50
+ this.transport = null;
51
+ throw new Error(`Failed to connect: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ }
54
+ /**
55
+ * Disconnect from server
56
+ */
57
+ async disconnect() {
58
+ if (this.transport) {
59
+ try {
60
+ await this.transport.close();
61
+ }
62
+ catch (error) {
63
+ // Silently ignore disconnect errors in tests
64
+ // In production, you'd want proper logging
65
+ }
66
+ finally {
67
+ this.transport = null;
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * Get session ID from transport (useful for testing)
73
+ */
74
+ getSessionId() {
75
+ return this.transport?.sessionId;
76
+ }
77
+ /**
78
+ * Update custom headers (creates new connection if already connected)
79
+ */
80
+ setHeaders(headers) {
81
+ if (this.transport) {
82
+ throw new Error('Cannot update headers while connected. Disconnect first.');
83
+ }
84
+ this.customHeaders = headers;
85
+ }
86
+ /**
87
+ * List available tools from server
88
+ */
89
+ async listTools() {
90
+ if (!this.transport) {
91
+ throw new Error('Client is not connected');
92
+ }
93
+ try {
94
+ const response = await this.client.listTools();
95
+ return response;
96
+ }
97
+ catch (error) {
98
+ throw new Error(`Failed to list tools: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ }
101
+ /**
102
+ * Call a tool on the server
103
+ */
104
+ async callTool(name, arguments_ = {}) {
105
+ if (!this.transport) {
106
+ throw new Error('Client is not connected');
107
+ }
108
+ try {
109
+ const response = await this.client.callTool({ name, arguments: arguments_ });
110
+ return response;
111
+ }
112
+ catch (error) {
113
+ throw new Error(`Failed to call tool '${name}': ${error instanceof Error ? error.message : String(error)}`);
114
+ }
115
+ }
116
+ /**
117
+ * Get client connection status
118
+ */
119
+ get isConnected() {
120
+ return this.transport !== null;
121
+ }
122
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Remote Authorization Test Suite
3
+ * Tests remote auth functionality with mock GitLab server
4
+ */
5
+ import { describe, test, after, before } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
+ import { CustomHeaderClient } from './clients/custom-header-client.js';
10
+ // Test constants
11
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
12
+ const TEST_PROJECT_ID = '123';
13
+ // Port ranges to avoid collisions
14
+ const MOCK_GITLAB_PORT_BASE = 9000;
15
+ const MOCK_GITLAB_PORT_OFFSET = 500; // Offset for timeout test suite
16
+ const MCP_SERVER_PORT_BASE = 3000;
17
+ const MCP_SERVER_PORT_OFFSET = 500; // Offset for timeout test suite
18
+ // Timeout settings
19
+ const SESSION_TIMEOUT_SECONDS = 3;
20
+ const TIMEOUT_BUFFER_MS = 1000; // Extra time beyond timeout to ensure expiration
21
+ const TIMEOUT_TEST_WAIT_MS = SESSION_TIMEOUT_SECONDS * 1000 + TIMEOUT_BUFFER_MS;
22
+ const KEEPALIVE_INTERVAL_MS = 2000; // Must be less than SESSION_TIMEOUT_SECONDS
23
+ const KEEPALIVE_REQUEST_COUNT = 3; // Number of keepalive requests to test
24
+ console.log('🔐 Remote Authorization Test Suite');
25
+ console.log('');
26
+ describe('Remote Authorization - Basic Functionality', () => {
27
+ let mcpUrl;
28
+ let mockGitLab;
29
+ let servers = [];
30
+ before(async () => {
31
+ // Start mock GitLab server
32
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
33
+ mockGitLab = new MockGitLabServer({
34
+ port: mockPort,
35
+ validTokens: [MOCK_TOKEN]
36
+ });
37
+ await mockGitLab.start();
38
+ const mockGitLabUrl = mockGitLab.getUrl();
39
+ // Start MCP server with remote auth
40
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
41
+ const server = await launchServer({
42
+ mode: TransportMode.STREAMABLE_HTTP,
43
+ port: mcpPort,
44
+ timeout: 5000,
45
+ env: {
46
+ STREAMABLE_HTTP: 'true',
47
+ REMOTE_AUTHORIZATION: 'true',
48
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
49
+ GITLAB_READ_ONLY_MODE: 'true',
50
+ }
51
+ });
52
+ servers.push(server);
53
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
54
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
55
+ console.log(`MCP Server: ${mcpUrl}`);
56
+ });
57
+ after(async () => {
58
+ cleanupServers(servers);
59
+ if (mockGitLab) {
60
+ await mockGitLab.stop();
61
+ }
62
+ });
63
+ test('should connect with Authorization Bearer header', async () => {
64
+ const client = new CustomHeaderClient({
65
+ 'authorization': `Bearer ${MOCK_TOKEN}`
66
+ });
67
+ await client.connect(mcpUrl);
68
+ const tools = await client.listTools();
69
+ assert.ok(tools.tools.length > 0, 'Should have tools');
70
+ console.log(` ✓ Connected successfully, got ${tools.tools.length} tools`);
71
+ await client.disconnect();
72
+ });
73
+ test('should connect with Private-Token header', async () => {
74
+ const client = new CustomHeaderClient({
75
+ 'private-token': MOCK_TOKEN
76
+ });
77
+ await client.connect(mcpUrl);
78
+ const tools = await client.listTools();
79
+ assert.ok(tools.tools.length > 0, 'Should have tools');
80
+ console.log(` ✓ Connected with Private-Token, got ${tools.tools.length} tools`);
81
+ await client.disconnect();
82
+ });
83
+ test('should successfully call listTools with auth', async () => {
84
+ const client = new CustomHeaderClient({
85
+ 'authorization': `Bearer ${MOCK_TOKEN}`
86
+ });
87
+ await client.connect(mcpUrl);
88
+ // List tools multiple times to verify auth persists
89
+ const tools1 = await client.listTools();
90
+ const tools2 = await client.listTools();
91
+ const tools3 = await client.listTools();
92
+ assert.ok(tools1.tools.length > 0, 'Should have tools');
93
+ assert.strictEqual(tools1.tools.length, tools2.tools.length, 'Tool count should be consistent');
94
+ assert.strictEqual(tools2.tools.length, tools3.tools.length, 'Tool count should be consistent');
95
+ console.log(' ✓ Multiple tool list calls successful with persistent auth');
96
+ await client.disconnect();
97
+ });
98
+ test('should reject connection without auth header', async () => {
99
+ const client = new CustomHeaderClient({});
100
+ try {
101
+ await client.connect(mcpUrl);
102
+ await client.listTools();
103
+ await client.disconnect();
104
+ assert.fail('Should have rejected connection without auth');
105
+ }
106
+ catch (error) {
107
+ assert.ok(error instanceof Error);
108
+ console.log(' ✓ Correctly rejected connection without auth');
109
+ }
110
+ });
111
+ });
112
+ describe('Remote Authorization - Session Timeout', () => {
113
+ let mcpUrl;
114
+ let mockGitLab;
115
+ let servers = [];
116
+ before(async () => {
117
+ // Use different port ranges to avoid collisions with basic tests
118
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + MOCK_GITLAB_PORT_OFFSET);
119
+ mockGitLab = new MockGitLabServer({
120
+ port: mockPort,
121
+ validTokens: [MOCK_TOKEN]
122
+ });
123
+ await mockGitLab.start();
124
+ const mockGitLabUrl = mockGitLab.getUrl();
125
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + MCP_SERVER_PORT_OFFSET);
126
+ const server = await launchServer({
127
+ mode: TransportMode.STREAMABLE_HTTP,
128
+ port: mcpPort,
129
+ timeout: 5000,
130
+ env: {
131
+ STREAMABLE_HTTP: 'true',
132
+ REMOTE_AUTHORIZATION: 'true',
133
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
134
+ SESSION_TIMEOUT_SECONDS: String(SESSION_TIMEOUT_SECONDS),
135
+ }
136
+ });
137
+ servers.push(server);
138
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
139
+ console.log(`Session timeout: ${SESSION_TIMEOUT_SECONDS} seconds`);
140
+ });
141
+ after(async () => {
142
+ cleanupServers(servers);
143
+ if (mockGitLab) {
144
+ await mockGitLab.stop();
145
+ }
146
+ });
147
+ test('session timeout verification - activity keeps session alive', async () => {
148
+ const client = new CustomHeaderClient({
149
+ 'authorization': `Bearer ${MOCK_TOKEN}`
150
+ });
151
+ // Connect and verify it works
152
+ await client.connect(mcpUrl);
153
+ const tools1 = await client.listTools();
154
+ assert.ok(tools1.tools.length > 0, 'Initial connection should work');
155
+ console.log(' ✓ Initial connection successful');
156
+ // Keep connection alive with periodic requests
157
+ console.log(' Keeping session alive with requests...');
158
+ for (let i = 0; i < KEEPALIVE_REQUEST_COUNT; i++) {
159
+ await new Promise(resolve => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
160
+ const tools = await client.listTools();
161
+ assert.ok(tools.tools.length > 0, `Request ${i + 1} should succeed`);
162
+ console.log(` ✓ Request ${i + 1} succeeded (session still alive)`);
163
+ }
164
+ await client.disconnect();
165
+ console.log(' ✓ Session remained active with periodic requests');
166
+ });
167
+ test('session timeout expiration - inactivity expires auth', async () => {
168
+ // Step 1: Connect WITH auth header to establish session
169
+ const clientWithAuth = new CustomHeaderClient({
170
+ 'authorization': `Bearer ${MOCK_TOKEN}`
171
+ });
172
+ await clientWithAuth.connect(mcpUrl);
173
+ const tools1 = await clientWithAuth.listTools();
174
+ assert.ok(tools1.tools.length > 0, 'Initial connection should work');
175
+ console.log(' ✓ Initial connection successful with auth');
176
+ // Extract session ID using proper API
177
+ const sessionId = clientWithAuth.getSessionId();
178
+ assert.ok(sessionId, 'Session ID should exist');
179
+ console.log(` ℹ️ Session ID: ${sessionId}`);
180
+ // Step 2: Wait for timeout WITHOUT making any requests
181
+ console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
182
+ await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
183
+ // Step 3: Try to make request WITHOUT auth header - should fail with 401
184
+ try {
185
+ const response = await fetch(mcpUrl, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ 'mcp-session-id': sessionId,
190
+ },
191
+ body: JSON.stringify({
192
+ jsonrpc: '2.0',
193
+ id: 1,
194
+ method: 'tools/list'
195
+ })
196
+ });
197
+ if (!response.ok) {
198
+ assert.strictEqual(response.status, 401, 'Should return 401 Unauthorized');
199
+ console.log(` ✓ Request correctly failed with status ${response.status}`);
200
+ const body = await response.text();
201
+ console.log(` ℹ️ Error: ${body.substring(0, 100)}...`);
202
+ }
203
+ else {
204
+ assert.fail('Should have failed due to expired auth token');
205
+ }
206
+ }
207
+ catch (error) {
208
+ // Network errors are also acceptable
209
+ assert.ok(error instanceof Error, 'Should throw error');
210
+ console.log(' ✓ Request correctly failed after timeout');
211
+ console.log(` ℹ️ Error: ${error.message.substring(0, 100)}...`);
212
+ }
213
+ await clientWithAuth.disconnect();
214
+ });
215
+ });