@zereight/mcp-gitlab 2.1.55 → 2.1.57
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.ko.md +5 -5
- package/README.md +32 -29
- package/README.zh-CN.md +5 -5
- package/build/auth-retry.js +37 -20
- package/build/downloads/proxy.js +6 -6
- package/build/gitlab-client-pool.js +148 -112
- package/build/index.js +81 -16
- package/build/schemas.js +118 -8
- package/build/scripts/generate-tool-docs.js +1 -1
- package/build/server/request-helpers.js +4 -1
- package/build/test/no-proxy-test.js +11 -0
- package/build/test/remote-auth-tests.js +0 -1
- package/build/test/server/request-helpers.test.js +11 -1
- package/build/test/streamable-http-unauthenticated-discovery.test.js +48 -0
- package/build/test/test-auth-retry.js +39 -32
- package/build/test/test-permission-mode.js +1 -0
- package/build/test/test-toolset-filtering.js +2 -2
- package/build/test/test-webhooks.js +218 -0
- package/build/test-note.js +0 -1
- package/build/test-resolve-issue-note.js +0 -1
- package/build/tools/registry.js +21 -1
- package/package.json +3 -7
package/build/schemas.js
CHANGED
|
@@ -4091,6 +4091,14 @@ export const CreateTimelineEventSchema = z.object({
|
|
|
4091
4091
|
.describe("Timeline event tags to attach. Available: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'."),
|
|
4092
4092
|
});
|
|
4093
4093
|
// --- Webhook schemas ---
|
|
4094
|
+
function hasExactlyOneWebhookScope(data) {
|
|
4095
|
+
const hasProject = Boolean(data.project_id);
|
|
4096
|
+
const hasGroup = Boolean(data.group_id);
|
|
4097
|
+
return (hasProject || hasGroup) && !(hasProject && hasGroup);
|
|
4098
|
+
}
|
|
4099
|
+
const EXACTLY_ONE_WEBHOOK_SCOPE = {
|
|
4100
|
+
message: "Provide exactly one of project_id or group_id",
|
|
4101
|
+
};
|
|
4094
4102
|
export const ListWebhooksSchema = z
|
|
4095
4103
|
.object({
|
|
4096
4104
|
project_id: z.coerce
|
|
@@ -4103,9 +4111,7 @@ export const ListWebhooksSchema = z
|
|
|
4103
4111
|
.describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4104
4112
|
})
|
|
4105
4113
|
.merge(PaginationOptionsSchema)
|
|
4106
|
-
.refine(
|
|
4107
|
-
message: "Provide exactly one of project_id or group_id",
|
|
4108
|
-
});
|
|
4114
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4109
4115
|
export const ListWebhookEventsSchema = z
|
|
4110
4116
|
.object({
|
|
4111
4117
|
project_id: z.coerce
|
|
@@ -4128,9 +4134,7 @@ export const ListWebhookEventsSchema = z
|
|
|
4128
4134
|
per_page: z.number().max(20).optional().default(20).describe("Number of events per page"),
|
|
4129
4135
|
page: z.coerce.number().optional().describe("Page number for pagination"),
|
|
4130
4136
|
})
|
|
4131
|
-
.refine(
|
|
4132
|
-
message: "Provide exactly one of project_id or group_id",
|
|
4133
|
-
});
|
|
4137
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4134
4138
|
export const GetWebhookEventSchema = z
|
|
4135
4139
|
.object({
|
|
4136
4140
|
project_id: z.coerce
|
|
@@ -4148,9 +4152,115 @@ export const GetWebhookEventSchema = z
|
|
|
4148
4152
|
.optional()
|
|
4149
4153
|
.describe("If known, the page where the event is located (from list_webhook_events). Skips auto-pagination and fetches only this page."),
|
|
4150
4154
|
})
|
|
4151
|
-
.refine(
|
|
4152
|
-
|
|
4155
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4156
|
+
const WebhookCustomHeaderSchema = z.object({
|
|
4157
|
+
key: z.string().min(1).describe("Custom header name"),
|
|
4158
|
+
value: z.string().describe("Custom header value"),
|
|
4153
4159
|
});
|
|
4160
|
+
const WebhookMutationFields = {
|
|
4161
|
+
url: z.string().url().describe("Webhook destination URL"),
|
|
4162
|
+
name: z.string().optional().describe("Display name for the webhook"),
|
|
4163
|
+
description: z.string().optional().describe("Description of the webhook"),
|
|
4164
|
+
token: z
|
|
4165
|
+
.string()
|
|
4166
|
+
.optional()
|
|
4167
|
+
.describe("Secret token to validate received payloads. Not returned by GitLab in responses."),
|
|
4168
|
+
signing_token: z
|
|
4169
|
+
.string()
|
|
4170
|
+
.optional()
|
|
4171
|
+
.describe("HMAC signing token in whsec_<base64> form, where the Base64 suffix encodes a 32-byte key. Used for the webhook-signature header. Not returned by GitLab in responses."),
|
|
4172
|
+
enable_ssl_verification: z
|
|
4173
|
+
.boolean()
|
|
4174
|
+
.optional()
|
|
4175
|
+
.describe("Verify SSL when delivering the webhook"),
|
|
4176
|
+
push_events: z.boolean().optional().describe("Trigger on push events"),
|
|
4177
|
+
push_events_branch_filter: z
|
|
4178
|
+
.string()
|
|
4179
|
+
.optional()
|
|
4180
|
+
.describe("Only trigger push events for matching branches"),
|
|
4181
|
+
branch_filter_strategy: z
|
|
4182
|
+
.enum(["wildcard", "regex", "all_branches"])
|
|
4183
|
+
.optional()
|
|
4184
|
+
.describe("How push_events_branch_filter is interpreted"),
|
|
4185
|
+
issues_events: z.boolean().optional().describe("Trigger on issue events"),
|
|
4186
|
+
confidential_issues_events: z
|
|
4187
|
+
.boolean()
|
|
4188
|
+
.optional()
|
|
4189
|
+
.describe("Trigger on confidential issue events"),
|
|
4190
|
+
merge_requests_events: z.boolean().optional().describe("Trigger on merge request events"),
|
|
4191
|
+
tag_push_events: z.boolean().optional().describe("Trigger on tag push events"),
|
|
4192
|
+
note_events: z.boolean().optional().describe("Trigger on note/comment events"),
|
|
4193
|
+
confidential_note_events: z
|
|
4194
|
+
.boolean()
|
|
4195
|
+
.optional()
|
|
4196
|
+
.describe("Trigger on confidential note events"),
|
|
4197
|
+
job_events: z.boolean().optional().describe("Trigger on job events"),
|
|
4198
|
+
pipeline_events: z.boolean().optional().describe("Trigger on pipeline events"),
|
|
4199
|
+
wiki_page_events: z.boolean().optional().describe("Trigger on wiki page events"),
|
|
4200
|
+
deployment_events: z.boolean().optional().describe("Trigger on deployment events"),
|
|
4201
|
+
feature_flag_events: z.boolean().optional().describe("Trigger on feature flag events"),
|
|
4202
|
+
releases_events: z.boolean().optional().describe("Trigger on release events"),
|
|
4203
|
+
milestone_events: z.boolean().optional().describe("Trigger on milestone events"),
|
|
4204
|
+
resource_access_token_events: z
|
|
4205
|
+
.boolean()
|
|
4206
|
+
.optional()
|
|
4207
|
+
.describe("Trigger on access token expiry events"),
|
|
4208
|
+
resource_deploy_token_events: z
|
|
4209
|
+
.boolean()
|
|
4210
|
+
.optional()
|
|
4211
|
+
.describe("Trigger on project deploy token expiry events (project webhooks)"),
|
|
4212
|
+
member_events: z.boolean().optional().describe("Trigger on member events (group webhooks)"),
|
|
4213
|
+
project_events: z.boolean().optional().describe("Trigger on project events (group webhooks)"),
|
|
4214
|
+
subgroup_events: z.boolean().optional().describe("Trigger on subgroup events (group webhooks)"),
|
|
4215
|
+
custom_webhook_template: z
|
|
4216
|
+
.string()
|
|
4217
|
+
.optional()
|
|
4218
|
+
.describe("Custom JSON payload template for the webhook"),
|
|
4219
|
+
custom_headers: z
|
|
4220
|
+
.array(WebhookCustomHeaderSchema)
|
|
4221
|
+
.optional()
|
|
4222
|
+
.describe("Custom HTTP headers sent with the webhook. Each item has key and value strings."),
|
|
4223
|
+
};
|
|
4224
|
+
export const CreateWebhookSchema = z
|
|
4225
|
+
.object({
|
|
4226
|
+
project_id: z.coerce
|
|
4227
|
+
.string()
|
|
4228
|
+
.optional()
|
|
4229
|
+
.describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4230
|
+
group_id: z.coerce
|
|
4231
|
+
.string()
|
|
4232
|
+
.optional()
|
|
4233
|
+
.describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4234
|
+
...WebhookMutationFields,
|
|
4235
|
+
})
|
|
4236
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4237
|
+
export const UpdateWebhookSchema = z
|
|
4238
|
+
.object({
|
|
4239
|
+
project_id: z.coerce
|
|
4240
|
+
.string()
|
|
4241
|
+
.optional()
|
|
4242
|
+
.describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4243
|
+
group_id: z.coerce
|
|
4244
|
+
.string()
|
|
4245
|
+
.optional()
|
|
4246
|
+
.describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4247
|
+
hook_id: z.coerce.number().describe("ID of the webhook to update"),
|
|
4248
|
+
...WebhookMutationFields,
|
|
4249
|
+
})
|
|
4250
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4251
|
+
export const DeleteWebhookSchema = z
|
|
4252
|
+
.object({
|
|
4253
|
+
project_id: z.coerce
|
|
4254
|
+
.string()
|
|
4255
|
+
.optional()
|
|
4256
|
+
.describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4257
|
+
group_id: z.coerce
|
|
4258
|
+
.string()
|
|
4259
|
+
.optional()
|
|
4260
|
+
.describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
|
|
4261
|
+
hook_id: z.coerce.number().describe("ID of the webhook to delete"),
|
|
4262
|
+
})
|
|
4263
|
+
.refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
|
|
4154
4264
|
export const HealthCheckSchema = z.object({});
|
|
4155
4265
|
// --- CI/CD Variable types ---
|
|
4156
4266
|
export const GitLabCiVariableSchema = z.object({
|
|
@@ -101,7 +101,7 @@ const GROUP_META = {
|
|
|
101
101
|
},
|
|
102
102
|
webhooks: {
|
|
103
103
|
title: "Webhooks",
|
|
104
|
-
blurb: "
|
|
104
|
+
blurb: "Create, update, and delete project or group webhooks, and inspect recent webhook events.",
|
|
105
105
|
},
|
|
106
106
|
search: {
|
|
107
107
|
title: "Search",
|
|
@@ -34,7 +34,10 @@ export function isUnauthenticatedDiscoveryRequestBody(body) {
|
|
|
34
34
|
if (typeof m !== "object" || m === null)
|
|
35
35
|
return false;
|
|
36
36
|
const method = m.method;
|
|
37
|
-
return (method === "initialize" ||
|
|
37
|
+
return (method === "initialize" ||
|
|
38
|
+
method === "notifications/initialized" ||
|
|
39
|
+
method === "tools/list" ||
|
|
40
|
+
method === "server/discover");
|
|
38
41
|
};
|
|
39
42
|
if (Array.isArray(body))
|
|
40
43
|
return body.every(isDiscoveryMethod);
|
|
@@ -134,5 +134,16 @@ describe('NO_PROXY Pattern Matching', () => {
|
|
|
134
134
|
assert.strictEqual(agent1.constructor.name, 'Agent', 'Should match case-insensitively (lowercase)');
|
|
135
135
|
assert.strictEqual(agent2.constructor.name, 'Agent', 'Should match case-insensitively (uppercase)');
|
|
136
136
|
});
|
|
137
|
+
test('should re-evaluate NO_PROXY for redirect origins', () => {
|
|
138
|
+
const pool = new GitLabClientPool({
|
|
139
|
+
httpProxy: 'http://proxy.example.com:8080',
|
|
140
|
+
httpsProxy: 'http://proxy.example.com:8080',
|
|
141
|
+
noProxy: 'gitlab.internal.com',
|
|
142
|
+
});
|
|
143
|
+
const proxyToBypass = pool.getDispatcherForOrigin('https://gitlab.external.com/api/v4', 'https://gitlab.internal.com/api/v4');
|
|
144
|
+
const bypassToProxy = pool.getDispatcherForOrigin('https://gitlab.internal.com/api/v4', 'https://gitlab.external.com/api/v4');
|
|
145
|
+
assert.strictEqual(proxyToBypass.constructor.name, 'Agent', 'redirect onto NO_PROXY host should bypass');
|
|
146
|
+
assert.notStrictEqual(bypassToProxy.constructor.name, 'Agent', 'redirect onto proxied host should use proxy');
|
|
147
|
+
});
|
|
137
148
|
});
|
|
138
149
|
console.log('✅ NO_PROXY tests completed');
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, test, after, before } from 'node:test';
|
|
6
6
|
import assert from 'node:assert';
|
|
7
|
-
import fetch from 'node-fetch';
|
|
8
7
|
import { launchServer, findAvailablePort, cleanupServers, TransportMode, checkHealthEndpoint, HOST } from './utils/server-launcher.js';
|
|
9
8
|
console.log('🔐 Remote Authorization Tests');
|
|
10
9
|
console.log('');
|
|
@@ -16,12 +16,22 @@ describe("streamable request helpers", () => {
|
|
|
16
16
|
});
|
|
17
17
|
test("allows only discovery methods in unauthenticated discovery bodies", () => {
|
|
18
18
|
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "tools/list" }), true);
|
|
19
|
+
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody({ method: "server/discover" }), true);
|
|
19
20
|
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
|
|
20
21
|
{ method: "initialize" },
|
|
21
22
|
{ method: "notifications/initialized" },
|
|
22
23
|
{ method: "tools/list" },
|
|
24
|
+
{ method: "server/discover" },
|
|
23
25
|
]), true);
|
|
24
|
-
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
|
|
26
|
+
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
|
|
27
|
+
{ method: "tools/list" },
|
|
28
|
+
{ method: "call_tool" },
|
|
29
|
+
{ method: "tools/call" },
|
|
30
|
+
]), false);
|
|
31
|
+
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody([
|
|
32
|
+
{ method: "server/discover" },
|
|
33
|
+
{ method: "tools/call" },
|
|
34
|
+
]), false);
|
|
25
35
|
assert.strictEqual(isUnauthenticatedDiscoveryRequestBody(undefined), false);
|
|
26
36
|
});
|
|
27
37
|
test("normalizes Mcp-Session-Id headers", () => {
|
|
@@ -106,6 +106,54 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
|
|
|
106
106
|
assert.ok(Array.isArray(listResponse.data.result?.tools), "tools/list should return tools");
|
|
107
107
|
assert.ok(listResponse.data.result.tools.length > 0, "tools/list should not be empty");
|
|
108
108
|
});
|
|
109
|
+
test("allows unauthenticated server/discover in individual and batch requests", async () => {
|
|
110
|
+
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
111
|
+
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
112
|
+
});
|
|
113
|
+
servers.push(server);
|
|
114
|
+
const preInitDiscover = await rawMcpRequest(mcpUrl, {
|
|
115
|
+
jsonrpc: "2.0",
|
|
116
|
+
id: 10,
|
|
117
|
+
method: "server/discover",
|
|
118
|
+
params: {},
|
|
119
|
+
});
|
|
120
|
+
assert.strictEqual(preInitDiscover.status, 400, preInitDiscover.text);
|
|
121
|
+
assert.match(preInitDiscover.data?.error?.message ?? "", /Server not initialized/i, "server/discover should pass the auth gate before MCP initialization errors");
|
|
122
|
+
const sessionId = await initialize(mcpUrl);
|
|
123
|
+
const initialized = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sessionId });
|
|
124
|
+
assert.ok([200, 202, 204].includes(initialized.status), initialized.text);
|
|
125
|
+
const discoverResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 11, method: "server/discover", params: {} }, { "mcp-session-id": sessionId });
|
|
126
|
+
assert.strictEqual(discoverResponse.status, 200, discoverResponse.text);
|
|
127
|
+
assert.strictEqual(discoverResponse.data?.id, 11);
|
|
128
|
+
assert.strictEqual(discoverResponse.data?.error?.code, -32601);
|
|
129
|
+
assert.match(discoverResponse.data?.error?.message ?? "", /Method not found/i);
|
|
130
|
+
const batchResponse = await rawMcpRequest(mcpUrl, [
|
|
131
|
+
{ jsonrpc: "2.0", id: 12, method: "server/discover", params: {} },
|
|
132
|
+
{ jsonrpc: "2.0", id: 13, method: "tools/list", params: {} },
|
|
133
|
+
], { "mcp-session-id": sessionId });
|
|
134
|
+
assert.strictEqual(batchResponse.status, 200, batchResponse.text);
|
|
135
|
+
assert.ok(Array.isArray(batchResponse.data.result?.tools), "batch tools/list should return tools");
|
|
136
|
+
assert.ok(batchResponse.data.result.tools.length > 0, "batch tools/list should not be empty");
|
|
137
|
+
});
|
|
138
|
+
test("rejects unauthenticated discovery batch when tools/call is included", async () => {
|
|
139
|
+
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
140
|
+
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
141
|
+
});
|
|
142
|
+
servers.push(server);
|
|
143
|
+
const response = await rawMcpRequest(mcpUrl, [
|
|
144
|
+
{ jsonrpc: "2.0", id: 20, method: "server/discover", params: {} },
|
|
145
|
+
{
|
|
146
|
+
jsonrpc: "2.0",
|
|
147
|
+
id: 21,
|
|
148
|
+
method: "tools/call",
|
|
149
|
+
params: {
|
|
150
|
+
name: "get_project",
|
|
151
|
+
arguments: { project_id: "123" },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
assert.strictEqual(response.status, 401, response.text);
|
|
156
|
+
});
|
|
109
157
|
test("expires unauthenticated discovery sessions and frees capacity", async () => {
|
|
110
158
|
const { server, mcpUrl } = await launchRemoteAuthServer({
|
|
111
159
|
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
@@ -6,20 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { describe, test } from "node:test";
|
|
8
8
|
import assert from "node:assert";
|
|
9
|
-
import {
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { Response } from "undici";
|
|
10
11
|
import { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "../auth-retry.js";
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Helpers
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
12
|
function mockFetch(status) {
|
|
15
|
-
return
|
|
13
|
+
return async () => new Response("", { status });
|
|
16
14
|
}
|
|
17
15
|
function mockFetchThenRetry() {
|
|
18
16
|
let callCount = 0;
|
|
19
|
-
return
|
|
17
|
+
return async () => {
|
|
20
18
|
callCount++;
|
|
21
19
|
return new Response("", { status: callCount === 1 ? 401 : 200 });
|
|
22
|
-
}
|
|
20
|
+
};
|
|
23
21
|
}
|
|
24
22
|
function makeConfig(overrides) {
|
|
25
23
|
return {
|
|
@@ -30,9 +28,6 @@ function makeConfig(overrides) {
|
|
|
30
28
|
...overrides,
|
|
31
29
|
};
|
|
32
30
|
}
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// headersToPlainObject
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
31
|
describe("headersToPlainObject", () => {
|
|
37
32
|
test("null returns empty object", () => {
|
|
38
33
|
assert.deepStrictEqual(headersToPlainObject(null), {});
|
|
@@ -63,9 +58,6 @@ describe("headersToPlainObject", () => {
|
|
|
63
58
|
});
|
|
64
59
|
});
|
|
65
60
|
});
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// isNonReplayableBody
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
61
|
describe("isNonReplayableBody", () => {
|
|
70
62
|
test("null returns false", () => {
|
|
71
63
|
assert.strictEqual(isNonReplayableBody(null), false);
|
|
@@ -85,16 +77,15 @@ describe("isNonReplayableBody", () => {
|
|
|
85
77
|
test("object with .read() returns true (stream-like)", () => {
|
|
86
78
|
assert.strictEqual(isNonReplayableBody({ read: () => { } }), true);
|
|
87
79
|
});
|
|
88
|
-
test("
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
80
|
+
test("Web ReadableStream returns true", () => {
|
|
81
|
+
const stream = new ReadableStream({
|
|
82
|
+
start(controller) {
|
|
83
|
+
controller.close();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
assert.strictEqual(isNonReplayableBody(stream), true);
|
|
93
87
|
});
|
|
94
88
|
});
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// wrapWithAuthRetry
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
89
|
describe("wrapWithAuthRetry", () => {
|
|
99
90
|
test("non-401 response passes through unchanged", async () => {
|
|
100
91
|
const wrapped = wrapWithAuthRetry(mockFetch(200), makeConfig());
|
|
@@ -137,7 +128,29 @@ describe("wrapWithAuthRetry", () => {
|
|
|
137
128
|
});
|
|
138
129
|
const wrapped = wrapWithAuthRetry(mockFetch(401), config);
|
|
139
130
|
const res = await wrapped("http://example.com", {
|
|
140
|
-
body:
|
|
131
|
+
body: Readable.from([]),
|
|
132
|
+
duplex: "half",
|
|
133
|
+
});
|
|
134
|
+
assert.strictEqual(res.status, 401);
|
|
135
|
+
assert.strictEqual(refreshCalled, false);
|
|
136
|
+
});
|
|
137
|
+
test("401 with Web ReadableStream body skips retry", async () => {
|
|
138
|
+
let refreshCalled = false;
|
|
139
|
+
const config = makeConfig({
|
|
140
|
+
refreshToken: async () => {
|
|
141
|
+
refreshCalled = true;
|
|
142
|
+
return "tok";
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const stream = new ReadableStream({
|
|
146
|
+
start(controller) {
|
|
147
|
+
controller.close();
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const wrapped = wrapWithAuthRetry(mockFetch(401), config);
|
|
151
|
+
const res = await wrapped("http://example.com", {
|
|
152
|
+
body: stream,
|
|
153
|
+
duplex: "half",
|
|
141
154
|
});
|
|
142
155
|
assert.strictEqual(res.status, 401);
|
|
143
156
|
assert.strictEqual(refreshCalled, false);
|
|
@@ -148,27 +161,21 @@ describe("wrapWithAuthRetry", () => {
|
|
|
148
161
|
const config = makeConfig({
|
|
149
162
|
refreshToken: () => {
|
|
150
163
|
refreshCount++;
|
|
151
|
-
return new Promise(
|
|
164
|
+
return new Promise(resolve => {
|
|
152
165
|
resolveRefresh = resolve;
|
|
153
166
|
});
|
|
154
167
|
},
|
|
155
168
|
buildAuthHeaders: () => ({ Authorization: "Bearer stamped" }),
|
|
156
169
|
});
|
|
157
|
-
// Each call returns 401 first, then 200 on retry
|
|
158
170
|
let callCount = 0;
|
|
159
|
-
const base =
|
|
171
|
+
const base = async () => {
|
|
160
172
|
callCount++;
|
|
161
|
-
// First two calls are the initial requests (both 401)
|
|
162
|
-
// Next two are the retries (both 200)
|
|
163
173
|
return new Response("", { status: callCount <= 2 ? 401 : 200 });
|
|
164
|
-
}
|
|
174
|
+
};
|
|
165
175
|
const wrapped = wrapWithAuthRetry(base, config);
|
|
166
|
-
// Fire two concurrent requests
|
|
167
176
|
const p1 = wrapped("http://example.com/a");
|
|
168
177
|
const p2 = wrapped("http://example.com/b");
|
|
169
|
-
|
|
170
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
171
|
-
// Resolve the single pending refresh
|
|
178
|
+
await new Promise(r => setTimeout(r, 10));
|
|
172
179
|
resolveRefresh("stamped-token");
|
|
173
180
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
174
181
|
assert.strictEqual(r1.status, 200);
|
|
@@ -31,7 +31,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
31
31
|
users: 7,
|
|
32
32
|
search: 3,
|
|
33
33
|
workitems: 18,
|
|
34
|
-
webhooks:
|
|
34
|
+
webhooks: 6,
|
|
35
35
|
groups: 1,
|
|
36
36
|
variables: 10,
|
|
37
37
|
dependency_proxy: 4,
|
|
@@ -83,7 +83,7 @@ const TOOLSET_SAMPLE_TOOLS = {
|
|
|
83
83
|
tags: ["list_tags", "create_tag", "get_tag_signature"],
|
|
84
84
|
users: ["get_users", "upload_markdown", "download_attachment"],
|
|
85
85
|
search: ["search_code", "search_project_code", "search_group_code"],
|
|
86
|
-
webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
|
|
86
|
+
webhooks: ["list_webhooks", "create_webhook", "update_webhook", "delete_webhook", "list_webhook_events", "get_webhook_event"],
|
|
87
87
|
groups: ["create_group"],
|
|
88
88
|
variables: ["list_project_variables", "create_project_variable", "delete_project_variable", "list_group_variables", "create_group_variable", "delete_group_variable"],
|
|
89
89
|
dependency_proxy: ["get_dependency_proxy_settings", "list_dependency_proxy_blobs", "purge_dependency_proxy_cache"],
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-webhooks";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
const TEST_GROUP_ID = "my-group";
|
|
8
|
+
const TEST_HOOK_ID = 42;
|
|
9
|
+
const MOCK_PROJECT_HOOK = {
|
|
10
|
+
id: TEST_HOOK_ID,
|
|
11
|
+
url: "https://example.com/hook",
|
|
12
|
+
name: "AI Hook",
|
|
13
|
+
description: "Registered by MCP",
|
|
14
|
+
project_id: Number(TEST_PROJECT_ID),
|
|
15
|
+
push_events: true,
|
|
16
|
+
issues_events: false,
|
|
17
|
+
enable_ssl_verification: true,
|
|
18
|
+
};
|
|
19
|
+
const MOCK_GROUP_HOOK = {
|
|
20
|
+
id: TEST_HOOK_ID,
|
|
21
|
+
url: "https://example.com/group-hook",
|
|
22
|
+
name: "Group Hook",
|
|
23
|
+
group_id: 3,
|
|
24
|
+
push_events: true,
|
|
25
|
+
member_events: true,
|
|
26
|
+
enable_ssl_verification: true,
|
|
27
|
+
};
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
async function callTool(toolName, args, env) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
34
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
+
env: { ...process.env, ...env },
|
|
36
|
+
});
|
|
37
|
+
let output = "";
|
|
38
|
+
let errorOutput = "";
|
|
39
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
40
|
+
proc.stderr?.on("data", (d) => (errorOutput += d));
|
|
41
|
+
proc.on("close", code => {
|
|
42
|
+
if (code !== 0) {
|
|
43
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
44
|
+
}
|
|
45
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
46
|
+
if (!line)
|
|
47
|
+
return reject(new Error("No JSON output found"));
|
|
48
|
+
try {
|
|
49
|
+
const response = JSON.parse(line);
|
|
50
|
+
if (response.error) {
|
|
51
|
+
reject(new Error(response.error?.message ?? String(response.error)));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const content = response.result?.content?.[0]?.text;
|
|
55
|
+
resolve(content ? JSON.parse(content) : response.result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
reject(e);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
proc.stdin?.end(JSON.stringify({
|
|
63
|
+
jsonrpc: "2.0",
|
|
64
|
+
id: 1,
|
|
65
|
+
method: "tools/call",
|
|
66
|
+
params: { name: toolName, arguments: args },
|
|
67
|
+
}) + "\n");
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
describe("When managing webhooks", () => {
|
|
71
|
+
let mockServer;
|
|
72
|
+
let baseEnv;
|
|
73
|
+
let lastCreateBody;
|
|
74
|
+
let lastUpdateBody;
|
|
75
|
+
let lastDeletePath;
|
|
76
|
+
before(async () => {
|
|
77
|
+
mockServer = new MockGitLabServer({
|
|
78
|
+
port: await findMockServerPort(),
|
|
79
|
+
validTokens: [MOCK_TOKEN],
|
|
80
|
+
});
|
|
81
|
+
mockServer.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/hooks`, (req, res) => {
|
|
82
|
+
lastCreateBody = isRecord(req.body) ? req.body : undefined;
|
|
83
|
+
res.status(201).json({ ...MOCK_PROJECT_HOOK, ...lastCreateBody });
|
|
84
|
+
});
|
|
85
|
+
mockServer.addMockHandler("put", `/projects/${TEST_PROJECT_ID}/hooks/${TEST_HOOK_ID}`, (req, res) => {
|
|
86
|
+
lastUpdateBody = isRecord(req.body) ? req.body : undefined;
|
|
87
|
+
res.json({ ...MOCK_PROJECT_HOOK, ...lastUpdateBody, id: TEST_HOOK_ID });
|
|
88
|
+
});
|
|
89
|
+
mockServer.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/hooks/${TEST_HOOK_ID}`, (req, res) => {
|
|
90
|
+
lastDeletePath = req.path;
|
|
91
|
+
res.status(204).send();
|
|
92
|
+
});
|
|
93
|
+
mockServer.addMockHandler("post", `/groups/${TEST_GROUP_ID}/hooks`, (req, res) => {
|
|
94
|
+
lastCreateBody = isRecord(req.body) ? req.body : undefined;
|
|
95
|
+
res.status(201).json({ ...MOCK_GROUP_HOOK, ...lastCreateBody });
|
|
96
|
+
});
|
|
97
|
+
mockServer.addMockHandler("put", `/groups/${TEST_GROUP_ID}/hooks/${TEST_HOOK_ID}`, (req, res) => {
|
|
98
|
+
lastUpdateBody = isRecord(req.body) ? req.body : undefined;
|
|
99
|
+
res.json({ ...MOCK_GROUP_HOOK, ...lastUpdateBody, id: TEST_HOOK_ID });
|
|
100
|
+
});
|
|
101
|
+
mockServer.addMockHandler("delete", `/groups/${TEST_GROUP_ID}/hooks/${TEST_HOOK_ID}`, (req, res) => {
|
|
102
|
+
lastDeletePath = req.path;
|
|
103
|
+
res.status(204).send();
|
|
104
|
+
});
|
|
105
|
+
await mockServer.start();
|
|
106
|
+
baseEnv = {
|
|
107
|
+
GITLAB_API_URL: mockServer.getUrl() + "/api/v4",
|
|
108
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
109
|
+
GITLAB_TOOLSETS: "webhooks",
|
|
110
|
+
GITLAB_READ_ONLY_MODE: "false",
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
after(async () => {
|
|
114
|
+
if (mockServer)
|
|
115
|
+
await mockServer.stop();
|
|
116
|
+
});
|
|
117
|
+
describe("with create_webhook on a project", () => {
|
|
118
|
+
test("should create a webhook and return the GitLab response", async () => {
|
|
119
|
+
lastCreateBody = undefined;
|
|
120
|
+
const result = await callTool("create_webhook", {
|
|
121
|
+
project_id: TEST_PROJECT_ID,
|
|
122
|
+
url: "https://example.com/hook",
|
|
123
|
+
name: "AI Hook",
|
|
124
|
+
push_events: true,
|
|
125
|
+
issues_events: true,
|
|
126
|
+
}, baseEnv);
|
|
127
|
+
assert.deepStrictEqual(lastCreateBody, {
|
|
128
|
+
url: "https://example.com/hook",
|
|
129
|
+
name: "AI Hook",
|
|
130
|
+
push_events: true,
|
|
131
|
+
issues_events: true,
|
|
132
|
+
});
|
|
133
|
+
assert.ok(isRecord(result));
|
|
134
|
+
assert.strictEqual(result.url, "https://example.com/hook");
|
|
135
|
+
assert.strictEqual(result.name, "AI Hook");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe("with update_webhook on a project", () => {
|
|
139
|
+
test("should update a webhook and return the GitLab response", async () => {
|
|
140
|
+
lastUpdateBody = undefined;
|
|
141
|
+
const result = await callTool("update_webhook", {
|
|
142
|
+
project_id: TEST_PROJECT_ID,
|
|
143
|
+
hook_id: TEST_HOOK_ID,
|
|
144
|
+
url: "https://example.com/hook-updated",
|
|
145
|
+
name: "Renamed Hook",
|
|
146
|
+
}, baseEnv);
|
|
147
|
+
assert.deepStrictEqual(lastUpdateBody, {
|
|
148
|
+
url: "https://example.com/hook-updated",
|
|
149
|
+
name: "Renamed Hook",
|
|
150
|
+
});
|
|
151
|
+
assert.ok(isRecord(result));
|
|
152
|
+
assert.strictEqual(result.url, "https://example.com/hook-updated");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("with delete_webhook on a project", () => {
|
|
156
|
+
test("should delete a webhook and return a deleted status", async () => {
|
|
157
|
+
lastDeletePath = undefined;
|
|
158
|
+
const result = await callTool("delete_webhook", {
|
|
159
|
+
project_id: TEST_PROJECT_ID,
|
|
160
|
+
hook_id: TEST_HOOK_ID,
|
|
161
|
+
}, baseEnv);
|
|
162
|
+
assert.strictEqual(lastDeletePath, `/projects/${TEST_PROJECT_ID}/hooks/${TEST_HOOK_ID}`);
|
|
163
|
+
assert.deepStrictEqual(result, { status: "deleted", hook_id: TEST_HOOK_ID });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("with create_webhook on a group", () => {
|
|
167
|
+
test("should create a group webhook", async () => {
|
|
168
|
+
lastCreateBody = undefined;
|
|
169
|
+
const result = await callTool("create_webhook", {
|
|
170
|
+
group_id: TEST_GROUP_ID,
|
|
171
|
+
url: "https://example.com/group-hook",
|
|
172
|
+
member_events: true,
|
|
173
|
+
}, baseEnv);
|
|
174
|
+
assert.ok(lastCreateBody);
|
|
175
|
+
assert.strictEqual(lastCreateBody["url"], "https://example.com/group-hook");
|
|
176
|
+
assert.strictEqual(lastCreateBody["member_events"], true);
|
|
177
|
+
assert.ok(isRecord(result));
|
|
178
|
+
assert.strictEqual(result["url"], "https://example.com/group-hook");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe("with update_webhook on a group", () => {
|
|
182
|
+
test("should update a group webhook", async () => {
|
|
183
|
+
lastUpdateBody = undefined;
|
|
184
|
+
const result = await callTool("update_webhook", {
|
|
185
|
+
group_id: TEST_GROUP_ID,
|
|
186
|
+
hook_id: TEST_HOOK_ID,
|
|
187
|
+
url: "https://example.com/group-hook-updated",
|
|
188
|
+
name: "Renamed Group Hook",
|
|
189
|
+
}, baseEnv);
|
|
190
|
+
assert.deepStrictEqual(lastUpdateBody, {
|
|
191
|
+
url: "https://example.com/group-hook-updated",
|
|
192
|
+
name: "Renamed Group Hook",
|
|
193
|
+
});
|
|
194
|
+
assert.ok(isRecord(result));
|
|
195
|
+
assert.strictEqual(result.url, "https://example.com/group-hook-updated");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
describe("with delete_webhook on a group", () => {
|
|
199
|
+
test("should delete a group webhook", async () => {
|
|
200
|
+
lastDeletePath = undefined;
|
|
201
|
+
const result = await callTool("delete_webhook", {
|
|
202
|
+
group_id: TEST_GROUP_ID,
|
|
203
|
+
hook_id: TEST_HOOK_ID,
|
|
204
|
+
}, baseEnv);
|
|
205
|
+
assert.strictEqual(lastDeletePath, `/groups/${TEST_GROUP_ID}/hooks/${TEST_HOOK_ID}`);
|
|
206
|
+
assert.deepStrictEqual(result, { status: "deleted", hook_id: TEST_HOOK_ID });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe("with invalid scope", () => {
|
|
210
|
+
test("should reject when both project_id and group_id are provided", async () => {
|
|
211
|
+
await assert.rejects(() => callTool("create_webhook", {
|
|
212
|
+
project_id: TEST_PROJECT_ID,
|
|
213
|
+
group_id: TEST_GROUP_ID,
|
|
214
|
+
url: "https://example.com/hook",
|
|
215
|
+
}, baseEnv), /project_id or group_id/);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|