@zereight/mcp-gitlab 2.1.20 → 2.1.21

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/index.js CHANGED
@@ -128,6 +128,8 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
128
128
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
129
129
  import { normalizeGitLabApiUrl } from "./utils/url.js";
130
130
  import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
131
+ import { graphqlQueryContainsWriteOperation } from "./utils/graphql-query.js";
132
+ import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
131
133
  import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
132
134
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
133
135
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
@@ -4594,8 +4596,15 @@ async function createWikiPage(projectId, title, content, format) {
4594
4596
  async function updateWikiPage(projectId, slug, title, content, format) {
4595
4597
  projectId = decodeURIComponent(projectId); // Decode project ID
4596
4598
  const body = {};
4597
- if (title)
4598
- body.title = title;
4599
+ if (title) {
4600
+ if (slug.includes("/") && !title.includes("/")) {
4601
+ const existing = await getWikiPage(projectId, slug);
4602
+ body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
4603
+ }
4604
+ else {
4605
+ body.title = title;
4606
+ }
4607
+ }
4599
4608
  if (content)
4600
4609
  body.content = content;
4601
4610
  if (format)
@@ -4672,8 +4681,15 @@ async function createGroupWikiPage(groupId, title, content, format) {
4672
4681
  async function updateGroupWikiPage(groupId, slug, title, content, format) {
4673
4682
  groupId = decodeURIComponent(groupId); // Decode group ID
4674
4683
  const body = {};
4675
- if (title)
4676
- body.title = title;
4684
+ if (title) {
4685
+ if (slug.includes("/") && !title.includes("/")) {
4686
+ const existing = await getGroupWikiPage(groupId, slug);
4687
+ body.title = resolveNestedWikiUpdateTitle(slug, title, existing.title);
4688
+ }
4689
+ else {
4690
+ body.title = title;
4691
+ }
4692
+ }
4677
4693
  if (content)
4678
4694
  body.content = content;
4679
4695
  if (format)
@@ -6357,6 +6373,9 @@ async function handleToolCall(params) {
6357
6373
  switch (params.name) {
6358
6374
  case "execute_graphql": {
6359
6375
  const args = ExecuteGraphQLSchema.parse(params.arguments);
6376
+ if (GITLAB_READ_ONLY_MODE && graphqlQueryContainsWriteOperation(args.query)) {
6377
+ throw new Error("execute_graphql does not allow mutation or subscription operations in read-only mode");
6378
+ }
6360
6379
  const apiUrl = new URL(getEffectiveApiUrl());
6361
6380
  // Build GraphQL endpoint preserving any instance subpath (e.g. /gitlab)
6362
6381
  const restPath = apiUrl.pathname || ""; // e.g. /api/v4 or /gitlab/api/v4
@@ -0,0 +1,33 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { graphqlQueryContainsWriteOperation } from "../../utils/graphql-query.js";
4
+ describe("When graphqlQueryContainsWriteOperation runs", () => {
5
+ describe("with read-only GraphQL documents", () => {
6
+ test("should allow explicit query operations", () => {
7
+ assert.equal(graphqlQueryContainsWriteOperation("query { project(fullPath: \"g/p\") { id } }"), false);
8
+ });
9
+ test("should allow shorthand query operations", () => {
10
+ assert.equal(graphqlQueryContainsWriteOperation("{ project { id } }"), false);
11
+ });
12
+ test("should ignore mutation text inside comments", () => {
13
+ assert.equal(graphqlQueryContainsWriteOperation("# mutation destroy\nquery { project { id } }"), false);
14
+ });
15
+ test("should ignore mutation text inside string literals", () => {
16
+ assert.equal(graphqlQueryContainsWriteOperation('query { search(query: "mutation") { nodes { id } } }'), false);
17
+ });
18
+ });
19
+ describe("with write GraphQL documents", () => {
20
+ test("should detect mutation operations", () => {
21
+ assert.equal(graphqlQueryContainsWriteOperation('mutation { destroyProject(input: { projectId: "gid://gitlab/Project/1" }) { errors } }'), true);
22
+ });
23
+ test("should detect subscription operations", () => {
24
+ assert.equal(graphqlQueryContainsWriteOperation("subscription { mergeRequestCreated { id } }"), true);
25
+ });
26
+ test("should detect write operations in multi-operation documents", () => {
27
+ assert.equal(graphqlQueryContainsWriteOperation("query A { a } mutation B { b }"), true);
28
+ });
29
+ test("should detect semicolon-separated write operations", () => {
30
+ assert.equal(graphqlQueryContainsWriteOperation("query A { a }; mutation B { b }"), true);
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,21 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { resolveNestedWikiUpdateTitle } from "../../utils/wiki-title.js";
4
+ describe("When resolveNestedWikiUpdateTitle runs", () => {
5
+ describe("with nested wiki slugs", () => {
6
+ test("should prefix leaf titles using the existing hierarchical title", () => {
7
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
8
+ });
9
+ test("should prefix leaf titles using the slug parent when the existing title is flat", () => {
10
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "infra servers v2", "infra servers"), "00-map/infra servers v2");
11
+ });
12
+ test("should keep full hierarchical titles unchanged", () => {
13
+ assert.equal(resolveNestedWikiUpdateTitle("00-map/infra-servers", "00-map/infra servers v2", "00-map/infra servers"), "00-map/infra servers v2");
14
+ });
15
+ });
16
+ describe("with flat wiki slugs", () => {
17
+ test("should keep leaf titles unchanged", () => {
18
+ assert.equal(resolveNestedWikiUpdateTitle("infra-servers", "infra servers v2", "infra servers"), "infra servers v2");
19
+ });
20
+ });
21
+ });
@@ -0,0 +1,52 @@
1
+ function stripGraphQLCommentsAndStrings(source) {
2
+ let result = "";
3
+ let i = 0;
4
+ while (i < source.length) {
5
+ const ch = source[i];
6
+ if (ch === "#") {
7
+ while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
8
+ i++;
9
+ }
10
+ result += " ";
11
+ continue;
12
+ }
13
+ if (ch === '"' || ch === "'") {
14
+ const quote = ch;
15
+ i++;
16
+ while (i < source.length) {
17
+ if (source[i] === "\\") {
18
+ i = Math.min(i + 2, source.length);
19
+ continue;
20
+ }
21
+ if (source[i] === quote) {
22
+ i++;
23
+ break;
24
+ }
25
+ i++;
26
+ }
27
+ result += " ";
28
+ continue;
29
+ }
30
+ if (source.slice(i, i + 3) === '"""') {
31
+ i += 3;
32
+ while (i < source.length && source.slice(i, i + 3) !== '"""') {
33
+ i++;
34
+ }
35
+ if (i < source.length) {
36
+ i += 3;
37
+ }
38
+ result += " ";
39
+ continue;
40
+ }
41
+ result += ch;
42
+ i++;
43
+ }
44
+ return result;
45
+ }
46
+ export function graphqlQueryContainsWriteOperation(query) {
47
+ const normalized = stripGraphQLCommentsAndStrings(query).trim();
48
+ if (!normalized) {
49
+ return false;
50
+ }
51
+ return /(?:^|[};]\s*)(mutation|subscription)\b/.test(normalized);
52
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Preserve nested wiki hierarchy when callers pass a leaf-only title on update.
3
+ */
4
+ export function resolveNestedWikiUpdateTitle(slug, providedTitle, existingTitle) {
5
+ if (providedTitle.includes("/") || !slug.includes("/")) {
6
+ return providedTitle;
7
+ }
8
+ const titleParentIndex = existingTitle.lastIndexOf("/");
9
+ if (titleParentIndex >= 0) {
10
+ return `${existingTitle.slice(0, titleParentIndex)}/${providedTitle}`;
11
+ }
12
+ const slugParentIndex = slug.lastIndexOf("/");
13
+ if (slugParentIndex >= 0) {
14
+ return `${slug.slice(0, slugParentIndex)}/${providedTitle}`;
15
+ }
16
+ return providedTitle;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.20",
3
+ "version": "2.1.21",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [
@@ -51,7 +51,7 @@
51
51
  "changelog": "auto-changelog -p",
52
52
  "test": "npm run test:all",
53
53
  "test:all": "npm run build && npm run test:mock && npm run test:live",
54
- "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
54
+ "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
55
55
  "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
56
56
  "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
57
57
  "test:live": "node test/validate-api.js",