@zereight/mcp-gitlab 2.1.70 → 2.2.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/README.ko.md +3 -1
- package/README.md +6 -2
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +3 -1
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +369 -184
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/credentials.test.js +0 -153
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-api-tool-errors.test.js +0 -112
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-download-url.test.js +0 -157
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -804
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -484
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -141
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- package/build/test-resolve-issue-note.js +0 -127
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Callback Proxy Mode Tests
|
|
3
|
-
*
|
|
4
|
-
* Tests for GITLAB_OAUTH_CALLBACK_PROXY=true — the mode where the MCP server
|
|
5
|
-
* intercepts the OAuth callback from GitLab, exchanges the code for tokens
|
|
6
|
-
* server-side, and redirects to the MCP client with a proxy code.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, test, after, before } from "node:test";
|
|
9
|
-
import assert from "node:assert";
|
|
10
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
-
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
12
|
-
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
13
|
-
const MOCK_GITLAB_PORT_BASE = 9400;
|
|
14
|
-
const MCP_SERVER_PORT_BASE = 3400;
|
|
15
|
-
const MOCK_ACCESS_TOKEN = "ya29.mock-callback-proxy-token-123456";
|
|
16
|
-
const MOCK_REFRESH_TOKEN = "mock-refresh-token-abcdef";
|
|
17
|
-
const MOCK_APP_ID = "test-callback-proxy-app-id";
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Helpers
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
function generatePKCE() {
|
|
22
|
-
const verifier = randomBytes(32).toString("base64url");
|
|
23
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
24
|
-
return { verifier, challenge };
|
|
25
|
-
}
|
|
26
|
-
/** Register a client via DCR and return the virtual client_id. */
|
|
27
|
-
async function registerClient(mcpBaseUrl, redirectUri) {
|
|
28
|
-
const res = await fetch(`${mcpBaseUrl}/register`, {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { "Content-Type": "application/json" },
|
|
31
|
-
body: JSON.stringify({
|
|
32
|
-
redirect_uris: [redirectUri],
|
|
33
|
-
client_name: "callback-proxy-test",
|
|
34
|
-
}),
|
|
35
|
-
});
|
|
36
|
-
assert.strictEqual(res.status, 201, `DCR failed: ${res.status}`);
|
|
37
|
-
const data = (await res.json());
|
|
38
|
-
return data.client_id;
|
|
39
|
-
}
|
|
40
|
-
/** Hit /authorize and return the redirect Location without following it. */
|
|
41
|
-
async function authorize(mcpBaseUrl, clientId, redirectUri, codeChallenge, state) {
|
|
42
|
-
const params = new URLSearchParams({
|
|
43
|
-
response_type: "code",
|
|
44
|
-
client_id: clientId,
|
|
45
|
-
redirect_uri: redirectUri,
|
|
46
|
-
code_challenge: codeChallenge,
|
|
47
|
-
code_challenge_method: "S256",
|
|
48
|
-
state,
|
|
49
|
-
scope: "api",
|
|
50
|
-
});
|
|
51
|
-
const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
|
|
52
|
-
redirect: "manual",
|
|
53
|
-
});
|
|
54
|
-
assert.strictEqual(res.status, 302, `Expected 302, got ${res.status}`);
|
|
55
|
-
const location = res.headers.get("location");
|
|
56
|
-
assert.ok(location, "Missing Location header");
|
|
57
|
-
return new URL(location);
|
|
58
|
-
}
|
|
59
|
-
/** Simulate GitLab redirecting to the MCP server's /callback. */
|
|
60
|
-
async function simulateGitLabCallback(mcpBaseUrl, code, state) {
|
|
61
|
-
const res = await fetch(`${mcpBaseUrl}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, { redirect: "manual" });
|
|
62
|
-
return {
|
|
63
|
-
status: res.status,
|
|
64
|
-
location: res.headers.get("location"),
|
|
65
|
-
body: await res.text(),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
/** Exchange a proxy code for tokens via /token. */
|
|
69
|
-
async function exchangeToken(mcpBaseUrl, clientId, code, codeVerifier, redirectUri) {
|
|
70
|
-
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
71
|
-
method: "POST",
|
|
72
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
-
body: new URLSearchParams({
|
|
74
|
-
grant_type: "authorization_code",
|
|
75
|
-
client_id: clientId,
|
|
76
|
-
code,
|
|
77
|
-
code_verifier: codeVerifier,
|
|
78
|
-
redirect_uri: redirectUri,
|
|
79
|
-
}).toString(),
|
|
80
|
-
});
|
|
81
|
-
return {
|
|
82
|
-
status: res.status,
|
|
83
|
-
body: (await res.json()),
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// Test suite
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
describe("Callback Proxy Mode", () => {
|
|
90
|
-
let mcpBaseUrl;
|
|
91
|
-
let mockGitLab;
|
|
92
|
-
let mockGitLabUrl;
|
|
93
|
-
let servers = [];
|
|
94
|
-
const clientRedirectUri = "http://localhost:19999/oauth/callback";
|
|
95
|
-
// Track the last code+verifier GitLab received so we can verify the proxy PKCE
|
|
96
|
-
let lastTokenRequest = {};
|
|
97
|
-
before(async () => {
|
|
98
|
-
const mockPort = await findMockServerPort();
|
|
99
|
-
mockGitLab = new MockGitLabServer({
|
|
100
|
-
port: mockPort,
|
|
101
|
-
validTokens: [MOCK_ACCESS_TOKEN],
|
|
102
|
-
});
|
|
103
|
-
await mockGitLab.start();
|
|
104
|
-
mockGitLabUrl = mockGitLab.getUrl();
|
|
105
|
-
// Mock GitLab token exchange — returns tokens and records the request
|
|
106
|
-
mockGitLab.addRootHandler("post", "/oauth/token", (req, res) => {
|
|
107
|
-
// Body may be URL-encoded (from handleCallback) — parse manually if needed
|
|
108
|
-
let body = req.body ?? {};
|
|
109
|
-
if (typeof body === "string") {
|
|
110
|
-
body = Object.fromEntries(new URLSearchParams(body));
|
|
111
|
-
}
|
|
112
|
-
else if (!body.grant_type && req.headers["content-type"]?.includes("urlencoded")) {
|
|
113
|
-
// express.json() didn't parse it — collect raw body
|
|
114
|
-
let raw = "";
|
|
115
|
-
req.on("data", (chunk) => { raw += chunk.toString(); });
|
|
116
|
-
req.on("end", () => {
|
|
117
|
-
lastTokenRequest = Object.fromEntries(new URLSearchParams(raw));
|
|
118
|
-
res.json({
|
|
119
|
-
access_token: MOCK_ACCESS_TOKEN,
|
|
120
|
-
token_type: "Bearer",
|
|
121
|
-
expires_in: 7200,
|
|
122
|
-
refresh_token: MOCK_REFRESH_TOKEN,
|
|
123
|
-
scope: "api",
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
lastTokenRequest = body;
|
|
129
|
-
res.json({
|
|
130
|
-
access_token: MOCK_ACCESS_TOKEN,
|
|
131
|
-
token_type: "Bearer",
|
|
132
|
-
expires_in: 7200,
|
|
133
|
-
refresh_token: MOCK_REFRESH_TOKEN,
|
|
134
|
-
scope: "api",
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
// Mock token introspection
|
|
138
|
-
mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
|
|
139
|
-
const auth = req.headers["authorization"];
|
|
140
|
-
const token = auth?.replace(/^Bearer\s+/i, "");
|
|
141
|
-
if (token !== MOCK_ACCESS_TOKEN) {
|
|
142
|
-
res.status(401).json({ error: "invalid_token" });
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
res.json({
|
|
146
|
-
resource_owner_id: 42,
|
|
147
|
-
scopes: ["api"],
|
|
148
|
-
expires_in_seconds: 7200,
|
|
149
|
-
application: { uid: MOCK_APP_ID },
|
|
150
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
154
|
-
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
155
|
-
const server = await launchServer({
|
|
156
|
-
mode: TransportMode.STREAMABLE_HTTP,
|
|
157
|
-
port: mcpPort,
|
|
158
|
-
timeout: 5000,
|
|
159
|
-
env: {
|
|
160
|
-
STREAMABLE_HTTP: "true",
|
|
161
|
-
GITLAB_MCP_OAUTH: "true",
|
|
162
|
-
GITLAB_OAUTH_CALLBACK_PROXY: "true",
|
|
163
|
-
GITLAB_OAUTH_APP_ID: MOCK_APP_ID,
|
|
164
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
165
|
-
MCP_SERVER_URL: mcpBaseUrl,
|
|
166
|
-
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
servers.push(server);
|
|
170
|
-
});
|
|
171
|
-
after(async () => {
|
|
172
|
-
cleanupServers(servers);
|
|
173
|
-
if (mockGitLab)
|
|
174
|
-
await mockGitLab.stop();
|
|
175
|
-
});
|
|
176
|
-
// ---- Happy path --------------------------------------------------------
|
|
177
|
-
test("full flow: authorize → callback → token exchange", async () => {
|
|
178
|
-
const clientPKCE = generatePKCE();
|
|
179
|
-
const clientState = "test-state-123";
|
|
180
|
-
// 1. Register client
|
|
181
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
182
|
-
// 2. Authorize — should redirect to GitLab with fixed callback URL
|
|
183
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, clientState);
|
|
184
|
-
assert.strictEqual(gitlabUrl.hostname, new URL(mockGitLabUrl).hostname);
|
|
185
|
-
assert.strictEqual(gitlabUrl.pathname, "/oauth/authorize");
|
|
186
|
-
// redirect_uri should be the MCP server's /callback, NOT the client's
|
|
187
|
-
assert.strictEqual(gitlabUrl.searchParams.get("redirect_uri"), `${mcpBaseUrl}/callback`);
|
|
188
|
-
// State should be a proxy state, NOT the client's original state
|
|
189
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
190
|
-
assert.ok(proxyState);
|
|
191
|
-
assert.notStrictEqual(proxyState, clientState);
|
|
192
|
-
// 3. Simulate GitLab callback to MCP server
|
|
193
|
-
const callbackResult = await simulateGitLabCallback(mcpBaseUrl, "gitlab-auth-code-xyz", proxyState);
|
|
194
|
-
assert.strictEqual(callbackResult.status, 302);
|
|
195
|
-
assert.ok(callbackResult.location);
|
|
196
|
-
const clientCallbackUrl = new URL(callbackResult.location);
|
|
197
|
-
// Should redirect to the CLIENT's callback URL
|
|
198
|
-
assert.ok(clientCallbackUrl.href.startsWith(clientRedirectUri));
|
|
199
|
-
// Should include a proxy code (not the GitLab code)
|
|
200
|
-
const proxyCode = clientCallbackUrl.searchParams.get("code");
|
|
201
|
-
assert.ok(proxyCode);
|
|
202
|
-
assert.notStrictEqual(proxyCode, "gitlab-auth-code-xyz");
|
|
203
|
-
// Should restore the client's original state
|
|
204
|
-
assert.strictEqual(clientCallbackUrl.searchParams.get("state"), clientState);
|
|
205
|
-
// 4. Exchange proxy code for tokens
|
|
206
|
-
const tokenResult = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
207
|
-
assert.strictEqual(tokenResult.status, 200);
|
|
208
|
-
assert.strictEqual(tokenResult.body.access_token, MOCK_ACCESS_TOKEN);
|
|
209
|
-
assert.strictEqual(tokenResult.body.refresh_token, MOCK_REFRESH_TOKEN);
|
|
210
|
-
});
|
|
211
|
-
// ---- One-time use proxy code -------------------------------------------
|
|
212
|
-
test("proxy code cannot be reused", async () => {
|
|
213
|
-
const clientPKCE = generatePKCE();
|
|
214
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
215
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-reuse");
|
|
216
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
217
|
-
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-reuse", proxyState);
|
|
218
|
-
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
219
|
-
// First exchange succeeds
|
|
220
|
-
const first = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
221
|
-
assert.strictEqual(first.status, 200);
|
|
222
|
-
// Second exchange with same code fails
|
|
223
|
-
const second = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
224
|
-
assert.notStrictEqual(second.status, 200);
|
|
225
|
-
});
|
|
226
|
-
// ---- Client binding -----------------------------------------------------
|
|
227
|
-
test("proxy code cannot be redeemed by a different client_id", async () => {
|
|
228
|
-
const clientPKCE = generatePKCE();
|
|
229
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
230
|
-
const otherClientId = await registerClient(mcpBaseUrl, "http://localhost:19998/oauth/callback");
|
|
231
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-client-binding");
|
|
232
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
233
|
-
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-client-binding", proxyState);
|
|
234
|
-
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
235
|
-
const result = await exchangeToken(mcpBaseUrl, otherClientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
|
|
236
|
-
assert.notStrictEqual(result.status, 200);
|
|
237
|
-
});
|
|
238
|
-
test("proxy code cannot be redeemed with a different redirect_uri", async () => {
|
|
239
|
-
const clientPKCE = generatePKCE();
|
|
240
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
241
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-redirect-binding");
|
|
242
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
243
|
-
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-redirect-binding", proxyState);
|
|
244
|
-
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
245
|
-
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, "http://localhost:19998/oauth/callback");
|
|
246
|
-
assert.notStrictEqual(result.status, 200);
|
|
247
|
-
});
|
|
248
|
-
// ---- Unknown state parameter -------------------------------------------
|
|
249
|
-
test("callback with unknown state returns 400", async () => {
|
|
250
|
-
const result = await simulateGitLabCallback(mcpBaseUrl, "some-code", "unknown-state-value");
|
|
251
|
-
assert.strictEqual(result.status, 400);
|
|
252
|
-
assert.ok(result.body.includes("Unknown or expired"));
|
|
253
|
-
});
|
|
254
|
-
// ---- PKCE verification failure -----------------------------------------
|
|
255
|
-
test("token exchange with wrong code_verifier fails", async () => {
|
|
256
|
-
const clientPKCE = generatePKCE();
|
|
257
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
258
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-pkce");
|
|
259
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
260
|
-
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-pkce", proxyState);
|
|
261
|
-
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
262
|
-
// Exchange with WRONG verifier
|
|
263
|
-
const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, "wrong-verifier-value", clientRedirectUri);
|
|
264
|
-
assert.notStrictEqual(result.status, 200);
|
|
265
|
-
});
|
|
266
|
-
// ---- PKCE verifier omitted ---------------------------------------------
|
|
267
|
-
test("token exchange without code_verifier fails when challenge was stored", async () => {
|
|
268
|
-
const clientPKCE = generatePKCE();
|
|
269
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
270
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-no-verifier");
|
|
271
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
272
|
-
const cb = await simulateGitLabCallback(mcpBaseUrl, "code-no-verifier", proxyState);
|
|
273
|
-
const proxyCode = new URL(cb.location).searchParams.get("code");
|
|
274
|
-
// Exchange WITHOUT verifier — should fail
|
|
275
|
-
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
276
|
-
method: "POST",
|
|
277
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
278
|
-
body: new URLSearchParams({
|
|
279
|
-
grant_type: "authorization_code",
|
|
280
|
-
client_id: clientId,
|
|
281
|
-
code: proxyCode,
|
|
282
|
-
redirect_uri: clientRedirectUri,
|
|
283
|
-
}).toString(),
|
|
284
|
-
});
|
|
285
|
-
assert.notStrictEqual(res.status, 200);
|
|
286
|
-
});
|
|
287
|
-
// ---- Replayed state parameter ------------------------------------------
|
|
288
|
-
test("state cannot be replayed after callback", async () => {
|
|
289
|
-
const clientPKCE = generatePKCE();
|
|
290
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
291
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-replay");
|
|
292
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
293
|
-
// First callback succeeds
|
|
294
|
-
const first = await simulateGitLabCallback(mcpBaseUrl, "code-replay-1", proxyState);
|
|
295
|
-
assert.strictEqual(first.status, 302);
|
|
296
|
-
// Second callback with same state fails
|
|
297
|
-
const second = await simulateGitLabCallback(mcpBaseUrl, "code-replay-2", proxyState);
|
|
298
|
-
assert.strictEqual(second.status, 400);
|
|
299
|
-
});
|
|
300
|
-
// ---- Dual PKCE verification --------------------------------------------
|
|
301
|
-
test("proxy uses its own PKCE pair with GitLab", async () => {
|
|
302
|
-
const clientPKCE = generatePKCE();
|
|
303
|
-
const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
|
|
304
|
-
const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-dual-pkce");
|
|
305
|
-
// The code_challenge sent to GitLab should NOT be the client's challenge
|
|
306
|
-
const gitlabChallenge = gitlabUrl.searchParams.get("code_challenge");
|
|
307
|
-
assert.ok(gitlabChallenge);
|
|
308
|
-
assert.notStrictEqual(gitlabChallenge, clientPKCE.challenge);
|
|
309
|
-
// Complete the flow to verify the proxy verifier was sent to GitLab
|
|
310
|
-
const proxyState = gitlabUrl.searchParams.get("state");
|
|
311
|
-
await simulateGitLabCallback(mcpBaseUrl, "code-dual-pkce", proxyState);
|
|
312
|
-
// The token request to GitLab should have a code_verifier that matches
|
|
313
|
-
// the proxy challenge (not the client's verifier)
|
|
314
|
-
assert.ok(lastTokenRequest.code_verifier);
|
|
315
|
-
assert.notStrictEqual(lastTokenRequest.code_verifier, clientPKCE.verifier);
|
|
316
|
-
const computedChallenge = createHash("sha256")
|
|
317
|
-
.update(lastTokenRequest.code_verifier)
|
|
318
|
-
.digest("base64url");
|
|
319
|
-
assert.strictEqual(computedChallenge, gitlabChallenge);
|
|
320
|
-
});
|
|
321
|
-
});
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { allTools } from "../../tools/registry.js";
|
|
4
|
-
import { CreateWebhookSchema, GetPipelineJobOutputSchema, ProtectBranchSchema, } from "../../schemas.js";
|
|
5
|
-
import { applyFlagAliases, CliUsageError, parseArgv, parseToolArgs } from "../../cli/args.js";
|
|
6
|
-
function argv(...args) {
|
|
7
|
-
return ["node", "index.js", ...args];
|
|
8
|
-
}
|
|
9
|
-
function schemaFor(toolName) {
|
|
10
|
-
const tool = allTools.find(entry => entry.name === toolName);
|
|
11
|
-
assert.ok(tool, `missing tool ${toolName}`);
|
|
12
|
-
return tool.inputSchema;
|
|
13
|
-
}
|
|
14
|
-
describe("When parseArgv reads a command line", () => {
|
|
15
|
-
describe("with a generic tool invocation", () => {
|
|
16
|
-
it("should keep positionals and kebab flags as snake_case tool args", () => {
|
|
17
|
-
const parsed = parseArgv(argv("tool", "list_issues", "--project-id", "123", "--state", "opened"));
|
|
18
|
-
assert.deepEqual(parsed.positionals, ["tool", "list_issues"]);
|
|
19
|
-
assert.equal(parsed.toolFlags.project_id, "123");
|
|
20
|
-
assert.equal(parsed.toolFlags.state, "opened");
|
|
21
|
-
assert.equal(parsed.output, undefined);
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
describe("with global flags mixed in", () => {
|
|
25
|
-
it("should not treat --token --output --yes as tool flags", () => {
|
|
26
|
-
const parsed = parseArgv(argv("tool", "whoami", "--token", "glpat-xxxxxxxxxxxxxxxxxxxx", "--output=json", "--yes"));
|
|
27
|
-
assert.equal(parsed.output, "json");
|
|
28
|
-
assert.equal(parsed.yes, true);
|
|
29
|
-
assert.deepEqual(parsed.toolFlags, {});
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
describe("with --args-json", () => {
|
|
33
|
-
it("should parse the object and ignore prototype keys", () => {
|
|
34
|
-
const parsed = parseArgv(argv("tool", "create_issue", "--args-json", '{"title":"bug","__proto__":{"x":1}}'));
|
|
35
|
-
assert.equal(parsed.argsJson?.title, "bug");
|
|
36
|
-
assert.equal(Object.prototype.hasOwnProperty.call(parsed.argsJson, "__proto__"), false);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
describe("with --use-oauth before a curated command", () => {
|
|
40
|
-
it("should keep mr list as positionals instead of consuming mr as the flag value", () => {
|
|
41
|
-
const parsed = parseArgv(argv("--use-oauth", "mr", "list"));
|
|
42
|
-
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
describe("with --read-only false before a curated command", () => {
|
|
46
|
-
it("should keep false as the flag value and mr list as positionals", () => {
|
|
47
|
-
const parsed = parseArgv(argv("--read-only", "false", "mr", "list"));
|
|
48
|
-
assert.deepEqual(parsed.positionals, ["mr", "list"]);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
describe("When parseToolArgs validates a real schema", () => {
|
|
53
|
-
describe("with whoami", () => {
|
|
54
|
-
it("should accept an empty flag set", () => {
|
|
55
|
-
const args = parseToolArgs({
|
|
56
|
-
schema: schemaFor("whoami"),
|
|
57
|
-
flags: {},
|
|
58
|
-
argsJson: undefined,
|
|
59
|
-
extraArgs: undefined,
|
|
60
|
-
});
|
|
61
|
-
assert.deepEqual(args, {});
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
describe("with create_issue missing title", () => {
|
|
65
|
-
it("should throw a usage error for the required flag", () => {
|
|
66
|
-
assert.throws(() => parseToolArgs({
|
|
67
|
-
schema: schemaFor("create_issue"),
|
|
68
|
-
flags: { project_id: "123" },
|
|
69
|
-
argsJson: undefined,
|
|
70
|
-
extraArgs: undefined,
|
|
71
|
-
}), (error) => error instanceof CliUsageError && error.message.includes("--title"));
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
describe("with flags overlaying --args-json", () => {
|
|
75
|
-
it("should let flags win", () => {
|
|
76
|
-
const args = parseToolArgs({
|
|
77
|
-
schema: schemaFor("create_issue"),
|
|
78
|
-
flags: { title: "from-flag" },
|
|
79
|
-
argsJson: { project_id: "123", title: "from-json" },
|
|
80
|
-
extraArgs: undefined,
|
|
81
|
-
});
|
|
82
|
-
assert.equal(args.project_id, "123");
|
|
83
|
-
assert.equal(args.title, "from-flag");
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
describe("with an unknown flag", () => {
|
|
87
|
-
it("should throw a usage error", () => {
|
|
88
|
-
assert.throws(() => parseToolArgs({
|
|
89
|
-
schema: schemaFor("whoami"),
|
|
90
|
-
flags: { nope: "1" },
|
|
91
|
-
argsJson: undefined,
|
|
92
|
-
extraArgs: undefined,
|
|
93
|
-
}), (error) => error instanceof CliUsageError && error.message === "Unknown flag: --nope");
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
describe("When parseToolArgs coerces scalar flags from the schema", () => {
|
|
98
|
-
describe("with a number flag", () => {
|
|
99
|
-
it("should pass a number that the tool schema accepts", () => {
|
|
100
|
-
const args = parseToolArgs({
|
|
101
|
-
schema: schemaFor("get_pipeline_job_output"),
|
|
102
|
-
flags: { project_id: "1", job_id: "9", limit: "10" },
|
|
103
|
-
argsJson: undefined,
|
|
104
|
-
extraArgs: undefined,
|
|
105
|
-
});
|
|
106
|
-
assert.equal(GetPipelineJobOutputSchema.safeParse(args).success, true);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe("with a boolean flag", () => {
|
|
110
|
-
it("should pass a boolean that the tool schema accepts", () => {
|
|
111
|
-
const args = parseToolArgs({
|
|
112
|
-
schema: schemaFor("create_webhook"),
|
|
113
|
-
flags: { project_id: "1", url: "https://example.com/hook", push_events: "false" },
|
|
114
|
-
argsJson: undefined,
|
|
115
|
-
extraArgs: undefined,
|
|
116
|
-
});
|
|
117
|
-
assert.equal(CreateWebhookSchema.safeParse(args).success, true);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
describe("with an integer flag", () => {
|
|
121
|
-
it("should pass an integer that the tool schema accepts", () => {
|
|
122
|
-
const args = parseToolArgs({
|
|
123
|
-
schema: schemaFor("protect_branch"),
|
|
124
|
-
flags: { project_id: "1", branch_name: "main", push_access_level: "40" },
|
|
125
|
-
argsJson: undefined,
|
|
126
|
-
extraArgs: undefined,
|
|
127
|
-
});
|
|
128
|
-
assert.strictEqual(args.push_access_level, 40);
|
|
129
|
-
assert.equal(ProtectBranchSchema.safeParse(args).success, true);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
describe("with a field that also accepts strings", () => {
|
|
133
|
-
it("should keep the raw string", () => {
|
|
134
|
-
const args = parseToolArgs({
|
|
135
|
-
schema: schemaFor("list_webhook_events"),
|
|
136
|
-
flags: { project_id: "1", hook_id: "2", status: "500" },
|
|
137
|
-
argsJson: undefined,
|
|
138
|
-
extraArgs: undefined,
|
|
139
|
-
});
|
|
140
|
-
assert.equal(args.status, "500");
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
describe("with a non-boolean value for a boolean flag", () => {
|
|
144
|
-
it("should throw a usage error", () => {
|
|
145
|
-
assert.throws(() => parseToolArgs({
|
|
146
|
-
schema: schemaFor("create_webhook"),
|
|
147
|
-
flags: { project_id: "1", url: "https://example.com/hook", push_events: "yes" },
|
|
148
|
-
argsJson: undefined,
|
|
149
|
-
extraArgs: undefined,
|
|
150
|
-
}), (error) => error instanceof CliUsageError && error.message === "--push-events must be true or false");
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
describe("with a fraction for an integer flag", () => {
|
|
154
|
-
it("should throw a usage error", () => {
|
|
155
|
-
assert.throws(() => parseToolArgs({
|
|
156
|
-
schema: schemaFor("protect_branch"),
|
|
157
|
-
flags: { project_id: "1", branch_name: "main", push_access_level: "40.5" },
|
|
158
|
-
argsJson: undefined,
|
|
159
|
-
extraArgs: undefined,
|
|
160
|
-
}), (error) => error instanceof CliUsageError &&
|
|
161
|
-
error.message === "--push-access-level must be an integer");
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
describe("When applyFlagAliases rewrites curated shorts", () => {
|
|
166
|
-
describe("with --mr-iid", () => {
|
|
167
|
-
it("should map onto merge_request_iid", () => {
|
|
168
|
-
const mapped = applyFlagAliases({ mr_iid: "45", project_id: "123" }, {
|
|
169
|
-
mr_iid: "merge_request_iid",
|
|
170
|
-
});
|
|
171
|
-
assert.equal(mapped.merge_request_iid, "45");
|
|
172
|
-
assert.equal(mapped.project_id, "123");
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as url from "node:url";
|
|
5
|
-
import { describe, it } from "node:test";
|
|
6
|
-
import { isBooleanFlagLiteral, nextBooleanFlagValue } from "../../cli-boolean-flags.js";
|
|
7
|
-
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
8
|
-
const SNAPSHOT_PATH = path.resolve(__dirname, "load-config-snapshot.ts");
|
|
9
|
-
function loadConfig(argv) {
|
|
10
|
-
const childEnv = {};
|
|
11
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
12
|
-
if (value !== undefined) {
|
|
13
|
-
childEnv[key] = value;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
delete childEnv.GITLAB_READ_ONLY_MODE;
|
|
17
|
-
delete childEnv.GITLAB_USE_OAUTH;
|
|
18
|
-
const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", SNAPSHOT_PATH, ...argv], {
|
|
19
|
-
env: childEnv,
|
|
20
|
-
encoding: "utf8",
|
|
21
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
-
});
|
|
23
|
-
return readBooleanConfigSnapshot(JSON.parse(stdout));
|
|
24
|
-
}
|
|
25
|
-
function readBooleanConfigSnapshot(value) {
|
|
26
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
-
throw new Error("config snapshot was not an object");
|
|
28
|
-
}
|
|
29
|
-
if (!("GITLAB_READ_ONLY_MODE" in value) || typeof value.GITLAB_READ_ONLY_MODE !== "boolean") {
|
|
30
|
-
throw new Error("GITLAB_READ_ONLY_MODE missing");
|
|
31
|
-
}
|
|
32
|
-
if (!("USE_OAUTH" in value) || typeof value.USE_OAUTH !== "boolean") {
|
|
33
|
-
throw new Error("USE_OAUTH missing");
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
GITLAB_READ_ONLY_MODE: value.GITLAB_READ_ONLY_MODE,
|
|
37
|
-
USE_OAUTH: value.USE_OAUTH,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
describe("When nextBooleanFlagValue reads the following token", () => {
|
|
41
|
-
describe("with a positional command", () => {
|
|
42
|
-
it("should treat the flag as true and leave the command", () => {
|
|
43
|
-
assert.deepEqual(nextBooleanFlagValue("mr"), { value: "true", consumeNext: false });
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
describe("with an explicit false", () => {
|
|
47
|
-
it("should consume the literal", () => {
|
|
48
|
-
assert.deepEqual(nextBooleanFlagValue("false"), { value: "false", consumeNext: true });
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
describe("with a missing token", () => {
|
|
52
|
-
it("should default to true", () => {
|
|
53
|
-
assert.deepEqual(nextBooleanFlagValue(undefined), { value: "true", consumeNext: false });
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
describe("When isBooleanFlagLiteral inspects a token", () => {
|
|
58
|
-
describe("with true", () => {
|
|
59
|
-
it("should accept it", () => {
|
|
60
|
-
assert.equal(isBooleanFlagLiteral("true"), true);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
describe("with a command name", () => {
|
|
64
|
-
it("should reject it", () => {
|
|
65
|
-
assert.equal(isBooleanFlagLiteral("mr"), false);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
describe("When config.ts parses space-separated boolean flags", () => {
|
|
70
|
-
describe("with --read-only as the last argument", () => {
|
|
71
|
-
it("should turn read-only mode on", () => {
|
|
72
|
-
const cfg = loadConfig(["--read-only"]);
|
|
73
|
-
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
describe("with --read-only followed by another flag", () => {
|
|
77
|
-
it("should turn read-only mode on", () => {
|
|
78
|
-
const cfg = loadConfig(["--read-only", "--use-pipeline"]);
|
|
79
|
-
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
describe("with an empty --read-only= assignment", () => {
|
|
83
|
-
it("should leave read-only mode off", () => {
|
|
84
|
-
const cfg = loadConfig(["--read-only="]);
|
|
85
|
-
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
describe("with --read-only false", () => {
|
|
89
|
-
it("should keep read-only off", () => {
|
|
90
|
-
const cfg = loadConfig(["--read-only", "false"]);
|
|
91
|
-
assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
describe("with --read-only before a command", () => {
|
|
95
|
-
it("should turn read-only on without consuming mr", () => {
|
|
96
|
-
const cfg = loadConfig(["--read-only", "mr", "list"]);
|
|
97
|
-
assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
describe("with --use-oauth false", () => {
|
|
101
|
-
it("should keep oauth off", () => {
|
|
102
|
-
const cfg = loadConfig(["--use-oauth", "false"]);
|
|
103
|
-
assert.equal(cfg.USE_OAUTH, false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
});
|