@zereight/mcp-gitlab 2.1.63 → 2.1.65

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.
Files changed (34) hide show
  1. package/README.ko.md +20 -5
  2. package/README.md +19 -6
  3. package/README.zh-CN.md +19 -6
  4. package/build/downloads/proxy.js +14 -7
  5. package/build/index.js +413 -455
  6. package/build/schemas.js +2 -2
  7. package/build/scripts/generate-tool-coverage.js +58 -0
  8. package/build/scripts/generate-tool-docs.js +6 -1
  9. package/build/scripts/tool-coverage/coverage.js +339 -0
  10. package/build/scripts/tool-coverage/coverage.test.js +142 -0
  11. package/build/test/path-segment-encoding.test.js +73 -17
  12. package/build/test/sse-session-limits.test.js +273 -0
  13. package/build/test/test-blank-filters.js +156 -0
  14. package/build/test/test-download-attachment.js +11 -0
  15. package/build/test/test-empty-toolsets.js +535 -0
  16. package/build/test/test-geteffectiveprojectid.js +1 -1
  17. package/build/test/test-job-artifacts.js +22 -0
  18. package/build/test/test-list-issues.js +47 -0
  19. package/build/test/test-list-merge-requests.js +76 -0
  20. package/build/test/test-list-milestones.js +145 -0
  21. package/build/test/test-permission-mode.js +103 -12
  22. package/build/test/test-remote-downloads.js +14 -1
  23. package/build/test/utils/graphql-query.test.js +72 -0
  24. package/build/test/utils/safe-redirect-fetch.test.js +463 -0
  25. package/build/test/utils/tool-args.test.js +89 -1
  26. package/build/test/utils/url.test.js +128 -0
  27. package/build/test-note.js +2 -1
  28. package/build/test-resolve-issue-note.js +4 -3
  29. package/build/tools/registry.js +16 -2
  30. package/build/utils/graphql-query.js +31 -9
  31. package/build/utils/safe-redirect-fetch.js +321 -0
  32. package/build/utils/tool-args.js +47 -3
  33. package/build/utils/url.js +102 -0
  34. package/package.json +2 -1
@@ -20,6 +20,41 @@ export function sanitizeToolArguments(toolName, args) {
20
20
  }
21
21
  return result;
22
22
  }
23
+ /**
24
+ * Drop blank string entries inside array filters so ["", "bug"] behaves like the scalar blank guard.
25
+ */
26
+ export function dropBlankArrayEntries(value) {
27
+ if (!Array.isArray(value)) {
28
+ return value;
29
+ }
30
+ return value.filter(item => !(typeof item === "string" && item.trim() === ""));
31
+ }
32
+ /**
33
+ * Blank filters must be omitted entirely instead of being serialized as empty query parameters
34
+ * such as `labels=`, `labels[]=` or `iids=`.
35
+ */
36
+ export function isBlankFilterValue(value) {
37
+ return (value === undefined ||
38
+ (typeof value === "string" && value.trim() === "") ||
39
+ (Array.isArray(value) && value.length === 0));
40
+ }
41
+ /**
42
+ * Append one query parameter, skipping blank filters and blank entries inside arrays.
43
+ * Booleans serialize as `true`/`false`, matching the inline branches this replaces.
44
+ */
45
+ export function appendFilterParam(searchParams, key, value) {
46
+ const normalized = dropBlankArrayEntries(value);
47
+ if (isBlankFilterValue(normalized)) {
48
+ return;
49
+ }
50
+ searchParams.append(key, String(normalized));
51
+ }
52
+ /** Append every option as a query parameter while dropping blank filters. */
53
+ export function appendFilterParams(searchParams, options) {
54
+ for (const [key, value] of Object.entries(options)) {
55
+ appendFilterParam(searchParams, key, value);
56
+ }
57
+ }
23
58
  /** Pairs where GitLab rejects sending both *_id and *_username query params. */
24
59
  export const LIST_ISSUES_ID_USERNAME_PAIRS = [
25
60
  ["author_id", "author_username"],
@@ -29,11 +64,20 @@ export const LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS = [
29
64
  ...LIST_ISSUES_ID_USERNAME_PAIRS,
30
65
  ["reviewer_id", "reviewer_username"],
31
66
  ];
67
+ /**
68
+ * Whether a username filter actually selects anything.
69
+ *
70
+ * Judged with the same helpers the list query serializers use, so the two agree: a blank
71
+ * value is not a value. Treating `[""]` or `" "` as one here would drop the id filter and
72
+ * then have the blank guard drop the username too, leaving no filter at all.
73
+ */
32
74
  function hasUsernameFilterValue(value) {
33
- if (Array.isArray(value)) {
34
- return value.length > 0;
75
+ // null never reaches here from tools (sanitizeToolArguments strips it);
76
+ // keep the old Boolean(null) === false behavior.
77
+ if (value === null) {
78
+ return false;
35
79
  }
36
- return Boolean(value);
80
+ return !isBlankFilterValue(dropBlankArrayEntries(value));
37
81
  }
38
82
  /**
39
83
  * When both id and username filters are set, GitLab returns 400. Prefer username and drop id.
@@ -17,3 +17,105 @@ export function normalizeGitLabApiUrl(url) {
17
17
  }
18
18
  return normalizedUrl;
19
19
  }
20
+ /**
21
+ * Dot segments anywhere in a decoded value, including after a percent-encoded
22
+ * separator ("..%2F..%2Fuser" decodes to a path-traversal sequence).
23
+ */
24
+ const DOT_SEGMENT_PATTERN = /(^|[\\/])\.\.?([\\/]|$)/;
25
+ /** A decoded value that starts with a path separator (absolute-path payload). */
26
+ const LEADING_SEPARATOR_PATTERN = /^[\\/]/;
27
+ /** NUL, newline and the other control characters: never valid in a path segment. */
28
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
29
+ /**
30
+ * How many times a value is percent-decoded before the checks run. A payload can be
31
+ * encoded more than once ("%252E%252E%252Fuser" only becomes "../user" after two
32
+ * decodes), so the checks run on the form the receiving server may eventually see.
33
+ */
34
+ const MAX_DECODE_PASSES = 5;
35
+ const INVALID_SEGMENT_MESSAGE = "Cannot use value as a GitLab URL path segment: it is or decodes to a '.'/'..' path segment, " +
36
+ "a control character such as NUL, or an absolute path";
37
+ const INVALID_PATH_MESSAGE = "Cannot use value as a GitLab URL path: it is empty or it has an empty, leading or trailing segment";
38
+ /**
39
+ * A percent escape that actually decodes to a byte: `%` followed by two hex digits.
40
+ *
41
+ * `decodeURIComponent` also throws for a `%` that starts no escape, and a literal
42
+ * percent sign is valid in a file or branch name ("report-100%.pdf"), so the two
43
+ * failures have to be told apart before either is treated as a broken payload.
44
+ */
45
+ const VALID_ESCAPE_PATTERN = /%[0-9a-fA-F]{2}/;
46
+ /**
47
+ * Percent-decode a value until decoding stops changing it.
48
+ *
49
+ * Returns null when the value cannot be reduced to a form that can be validated:
50
+ * a malformed escape next to a valid one, or more encoding layers than
51
+ * {@link MAX_DECODE_PASSES}. A guard must not treat "could not inspect the value"
52
+ * as "safe", so both cases are rejected by the callers rather than passed through.
53
+ *
54
+ * A `%` that starts no valid escape is a literal character instead: the value is
55
+ * returned as it is and the encoder turns the sign into `%25`, so
56
+ * `get_file_contents("report-100%.pdf")` keeps working. Only a value that mixes a
57
+ * real escape with a broken one cannot be inspected, and that form is rejected.
58
+ */
59
+ function decodeFully(value) {
60
+ let current = value;
61
+ for (let pass = 0; pass < MAX_DECODE_PASSES; pass++) {
62
+ let decoded;
63
+ try {
64
+ decoded = decodeURIComponent(current);
65
+ }
66
+ catch {
67
+ // Undecodable. With a valid escape left in it the value cannot be inspected
68
+ // reliably (the escape may still hide a separator or a dot segment), so it
69
+ // fails closed; without one every `%` is literal and `current` — already
70
+ // decoded as far as it can be — is the form the server will see.
71
+ return VALID_ESCAPE_PATTERN.test(current) ? null : current;
72
+ }
73
+ if (decoded === current) {
74
+ return current;
75
+ }
76
+ current = decoded;
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Encode a single value for use as one GitLab URL path segment.
82
+ *
83
+ * Rejects values that are, decode to, or start with dot segments or an absolute
84
+ * path, so a caller-supplied id cannot escape the intended route prefix.
85
+ * Percent-encoded separators are decoded before the check because the receiving
86
+ * server may treat them as separators; a value may be encoded any number of times
87
+ * and is still validated in its fully decoded form.
88
+ *
89
+ * @throws {Error} when the value is a dot segment, contains one, contains a control
90
+ * character, starts with a separator, or is not valid percent-encoding
91
+ */
92
+ export function encodeGitLabPathSegment(value) {
93
+ const segment = String(value);
94
+ const decodedSegment = decodeFully(segment);
95
+ if (decodedSegment === null ||
96
+ DOT_SEGMENT_PATTERN.test(decodedSegment) ||
97
+ LEADING_SEPARATOR_PATTERN.test(decodedSegment) ||
98
+ CONTROL_CHARACTER_PATTERN.test(decodedSegment)) {
99
+ throw new Error(INVALID_SEGMENT_MESSAGE);
100
+ }
101
+ return encodeURIComponent(decodedSegment);
102
+ }
103
+ /**
104
+ * Encode a slash-separated path so each segment is encoded and validated
105
+ * individually. Legitimate encoded separators (for example a tag such as
106
+ * "release%2F1.0") stay allowed; dot segments are rejected.
107
+ *
108
+ * An empty path, or one with an empty, leading or trailing segment, is rejected
109
+ * rather than silently collapsed: `"/etc/passwd"` would otherwise encode to a
110
+ * leading-separator path instead of the intended relative one.
111
+ *
112
+ * @throws {Error} when the path is empty or has an empty segment, or when any
113
+ * segment is a dot segment or contains one
114
+ */
115
+ export function encodeGitLabPath(value) {
116
+ const segments = value.split("/");
117
+ if (segments.some(segment => segment === "")) {
118
+ throw new Error(INVALID_PATH_MESSAGE);
119
+ }
120
+ return segments.map(encodeGitLabPathSegment).join("/");
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.63",
3
+ "version": "2.1.65",
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": [
@@ -65,6 +65,7 @@
65
65
  "lint": "eslint . --ext .ts",
66
66
  "check:runtime-deps": "node scripts/check-runtime-deps.mjs",
67
67
  "check:skill-sync": "tsx scripts/check-skill-sync.ts",
68
+ "docs:tools": "tsx scripts/generate-tool-docs.ts && tsx scripts/generate-tool-coverage.ts",
68
69
  "lint:fix": "eslint . --ext .ts --fix",
69
70
  "release": "bash scripts/release.sh",
70
71
  "release:mcp-registry": "bash scripts/publish_mcp_registry.sh",