Summary
validate_ci_lint throws instead of returning a result whenever the CI config is invalid — that is, in exactly the case the tool is useful for. On a valid config it works fine, so the bug stays invisible until the user actually has a broken .gitlab-ci.yml.
Cause
GitLab returns merged_yaml: null and includes: null when the config is invalid. GitLabCiLintResultSchema declares both as .optional():
https://github.com/zereight/gitlab-mcp/blob/main/schemas.ts#L317-L324
export const GitLabCiLintResultSchema = z.object({
valid: z.coerce.boolean(),
errors: z.array(z.string()),
warnings: z.array(z.string()).optional(),
merged_yaml: z.string().optional(),
includes: z.array(z.unknown()).optional(),
jobs: z.array(z.unknown()).optional(),
});
Zod's .optional() accepts undefined but rejects null, so .parse() throws in validateCiLint/validateProjectCiLint and the tool surfaces a schema error rather than the lint result.
This is the same class of bug as #575 (role-based protected-branch access levels), fixed there by allowing null.
Reproduction
Any invalid config will do. This one uses a job named image, which collides with the default: image: block:
default:
image: alpine:3.20
stages:
- build
image:
stage: build
script:
- echo build
Direct API — POST /projects/:id/ci/lint:
{"valid":false,"errors":["image is defined in top-level and `default:` entry"],"warnings":[],"merged_yaml":null,"includes":null}
Through the MCP tool — validate_ci_lint with the same content:
{"code":-32603,"message":"Invalid arguments: merged_yaml: Expected string, received null, includes: Expected array, received null"}
The actual explanation — image is defined in top-level and `default:` entry — never reaches the caller.
Expected
The tool returns the lint result with valid: false and the errors array, so the caller can see why the config is rejected.
Impact
A coding agent using this tool to validate CI changes cannot tell "the config is invalid" from "the tool is broken". In our case the agent concluded the tool was broken, silently fell back to prettier --check — which validates YAML formatting, not GitLab CI semantics — and reported success on a config that was invalid. Both error classes it needed to catch (a job named image, and needs pointing at a nonexistent job) are caught by the lint endpoint in under a second and are invisible to a formatter.
Environment
@zereight/mcp-gitlab 2.1.42 and 2.1.46 — reproduced on both
- self-hosted GitLab 19.2.0-ee
- transport: stdio, PAT auth,
GITLAB_PERMISSION_MODE=modify
- client: Claude Code
Suggested fix
.nullish() on the two fields GitLab actually nulls:
- merged_yaml: z.string().optional(),
- includes: z.array(z.unknown()).optional(),
+ merged_yaml: z.string().nullish(),
+ includes: z.array(z.unknown()).nullish(),
Happy to open a PR with a regression test in test/nullable-gitlab-response-fields.test.ts alongside the #575 cases.
Summary
validate_ci_lintthrows instead of returning a result whenever the CI config is invalid — that is, in exactly the case the tool is useful for. On a valid config it works fine, so the bug stays invisible until the user actually has a broken.gitlab-ci.yml.Cause
GitLab returns
merged_yaml: nullandincludes: nullwhen the config is invalid.GitLabCiLintResultSchemadeclares both as.optional():https://github.com/zereight/gitlab-mcp/blob/main/schemas.ts#L317-L324
Zod's
.optional()acceptsundefinedbut rejectsnull, so.parse()throws invalidateCiLint/validateProjectCiLintand the tool surfaces a schema error rather than the lint result.This is the same class of bug as #575 (role-based protected-branch access levels), fixed there by allowing
null.Reproduction
Any invalid config will do. This one uses a job named
image, which collides with thedefault: image:block:Direct API —
POST /projects/:id/ci/lint:{"valid":false,"errors":["image is defined in top-level and `default:` entry"],"warnings":[],"merged_yaml":null,"includes":null}Through the MCP tool —
validate_ci_lintwith the same content:{"code":-32603,"message":"Invalid arguments: merged_yaml: Expected string, received null, includes: Expected array, received null"}The actual explanation —
image is defined in top-level and `default:` entry— never reaches the caller.Expected
The tool returns the lint result with
valid: falseand theerrorsarray, so the caller can see why the config is rejected.Impact
A coding agent using this tool to validate CI changes cannot tell "the config is invalid" from "the tool is broken". In our case the agent concluded the tool was broken, silently fell back to
prettier --check— which validates YAML formatting, not GitLab CI semantics — and reported success on a config that was invalid. Both error classes it needed to catch (a job namedimage, andneedspointing at a nonexistent job) are caught by the lint endpoint in under a second and are invisible to a formatter.Environment
@zereight/mcp-gitlab2.1.42 and 2.1.46 — reproduced on bothGITLAB_PERMISSION_MODE=modifySuggested fix
.nullish()on the two fields GitLab actually nulls:Happy to open a PR with a regression test in
test/nullable-gitlab-response-fields.test.tsalongside the #575 cases.