Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .well-known/mcp/server-card.json
Original file line number Diff line number Diff line change
Expand Up @@ -10829,7 +10829,7 @@
},
{
"name": "rebase_merge_request",
"description": "Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.",
"description": "Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.",
"inputSchema": {
"type": "object",
"properties": {
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

##### Changed

- `rebase_merge_request` with `wait` true (default) now also returns `previous_sha` (the head `sha` before the rebase) and `head_changed`, so a caller sees at once whether the rebase moved the head ([#862](https://github.com/zereight/gitlab-mcp/pull/862)).
- Boolean tool arguments now parse the string `"false"` as `false` ([#848](https://github.com/zereight/gitlab-mcp/pull/848)). Other non-boolean strings such as `""` or `"yes"`, previously coerced to `true`, are now rejected with a validation error.

##### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ MR lifecycle — create, update, merge, approve, plus diff/conflict inspection a
| Tool | What it does | R/W |
|---|---|:-:|
| [`merge_merge_request`](merge-requests.md#merge_merge_request) | Merge a merge request. Use this only after checking the merge request approval, conflict, and pipeline state; use `approve_merge_request` to approve rather than merge. The operation changes repository state and may squash commits, schedule auto-merge, or delete the source branch, so it requires merge permission and returns GitLab's merge result or a mergeability error. Pass `sha` from `get_merge_request` (`sha` or `diff_refs.head_sha`); GitLab 19.2+ groups may reject merges without it. | ✏️ |
| [`rebase_merge_request`](merge-requests.md#rebase_merge_request) | Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. | ✏️ |
| [`rebase_merge_request`](merge-requests.md#rebase_merge_request) | Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch. | ✏️ |
| [`approve_merge_request`](merge-requests.md#approve_merge_request) | Approve a merge request. Use this to record an approval on an existing merge request; it does not merge the request or change its source branch. The operation changes review state, may require re-authentication or approval permission, and returns the updated approval result or a permission/state error. | ✏️ |
| [`unapprove_merge_request`](merge-requests.md#unapprove_merge_request) | Unapprove a merge request. Use this to remove the current user's approval from an existing merge request; use `merge_merge_request` only when you intend to merge. The operation changes review state and requires approval permission, and GitLab returns the updated result or an error when the request or approval is unavailable. | ✏️ |
| [`get_merge_request_approval_state`](merge-requests.md#get_merge_request_approval_state) | Get merge request approval details including approvers. Use this to inspect approval rules and approvers before deciding whether a merge request can be merged; use `approve_merge_request` to change approval state. It is read-only and returns the approval-state response, while missing requests, unsupported GitLab versions, and permission failures are reported as errors. | 📖 |
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/merge-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Merge a merge request. Use this only after checking the merge request approval,

*✏️ Writes*

Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.
Rebase the source branch of a merge request onto its target branch. Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.

**Parameters**

Expand Down
14 changes: 12 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,7 @@
})),
}));

mcpServer.server.setRequestHandler(GetPromptRequestSchema, async (request: any) => {

Check warning on line 864 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const prompt = MCP_PROMPTS.find(p => p.name === request.params.name);
if (!prompt) {
throw new Error(`Unknown prompt: ${request.params.name}`);
Expand All @@ -887,7 +887,7 @@
};
});

mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {

Check warning on line 890 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
// Manually retrieve the session context using the session ID passed in the request.
// This is a robust workaround for AsyncLocalStorage context loss.
const sessionId = request.params.sessionId;
Expand All @@ -898,7 +898,7 @@
request.params.arguments as Record<string, unknown> | undefined
);

const logCompletion = (result: any, completionOptions?: { skipJmespath?: boolean }) => {

Check warning on line 901 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
const durationMs = Date.now() - start;
logger.info(
{ tool: toolName, event: "tool_call_done", durationMs },
Expand All @@ -910,7 +910,7 @@
(toolName === "download_release_asset" && !IS_REMOTE);
const filtered = applyJmespathToToolResult(result, jmespathExpression, {
...completionOptions,
maskValue: maskingEngine ? (value: unknown) => maskingEngine!.maskValue(value) : undefined,

Check warning on line 913 in index.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
});
const masked = maskingEngine
? maskingEngine.maskToolResult(filtered, {
Expand Down Expand Up @@ -1791,7 +1791,7 @@
function defaultAuthRetryConfig() {
return {
isOAuthEnabled: () => USE_OAUTH && oauthClient != null,
refreshToken: (force: boolean) => oauthClient!.getAccessToken(force),

Check warning on line 1794 in index.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
onTokenRefreshed: (token: string) => {
OAUTH_ACCESS_TOKEN = token;
},
Expand Down Expand Up @@ -2659,7 +2659,7 @@
);

// Build request body, converting labels array to comma-separated string
const body: Record<string, any> = { ...options };

Check warning on line 2662 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
if (body.labels && Array.isArray(body.labels)) {
body.labels = body.labels.join(",");
}
Expand Down Expand Up @@ -2907,7 +2907,7 @@
);

// Convert labels array to comma-separated string if present
const body: Record<string, any> = { ...options };

Check warning on line 2910 in index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
if (body.labels && Array.isArray(body.labels)) {
body.labels = body.labels.join(",");
}
Expand Down Expand Up @@ -12520,17 +12520,27 @@
timeout_seconds,
poll_interval_seconds,
} = RebaseMergeRequestSchema.parse(params.arguments);
const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
if (!wait) {
const enqueued = await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
return { content: [{ type: "text", text: JSON.stringify(enqueued) }] };
}
const { mergeRequest: previous } = await getMergeRequestRebaseStatus(
project_id,
merge_request_iid
);
await rebaseMergeRequest(project_id, merge_request_iid, skip_ci);
const { mergeRequest } = await waitForStatus(
signal => getMergeRequestRebaseStatus(project_id, merge_request_iid, signal),
TERMINAL_REBASE_STATUSES,
timeout_seconds,
poll_interval_seconds
);
return { content: [{ type: "text", text: JSON.stringify(mergeRequest) }] };
const rebaseResult = {
...mergeRequest,
previous_sha: previous.sha,
head_changed: mergeRequest.sha !== previous.sha,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
return { content: [{ type: "text", text: JSON.stringify(rebaseResult) }] };
}

case "get_merge_request_approval_state": {
Expand Down
1 change: 1 addition & 0 deletions skills/gitlab-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Enable with `GITLAB_TOOLSETS=vulnerabilities` (requires GitLab Ultimate).
- **MR lookup**: provide `merge_request_iid` or `source_branch`
- **list_issues**: default scope = created by current user. Use `scope: "all"` for all issues
- **list_merge_requests**: without project_id returns user's MRs across all projects
- **rebase_merge_request**: with `wait` true (default) returns the merge request plus `previous_sha` and `head_changed`
- **CI catalog resource lookup**: provide exactly one of `id` or `full_path`
- **emoji reactions**: merge request, issue, and work item reaction tools use GitLab emoji names like `thumbsup`, `rocket`, or `eyes`
- **work items**: status and custom fields require GitLab Premium/Ultimate features
Expand Down
2 changes: 1 addition & 1 deletion skills/gitlab-mcp/reference/merge-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ rebase_merge_request
project_id: "my-group/my-project"
merge_request_iid: 42
skip_ci: true # optional, no pipeline for the rebased branch
wait: true # default; polls until the rebase ends
wait: true # default; polls until the rebase ends and returns previous_sha and head_changed
```

**Warning**: `rebase_merge_request` is destructive - it force-pushes the rebased source branch.
Expand Down
22 changes: 18 additions & 4 deletions test/test-rebase-merge-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,15 @@ describe("rebase_merge_request tool", () => {
let mockGitLabUrl: string;
const rebaseBodies = new Map<string, Record<string, unknown>>();
const statusQueries = new Map<string, Array<Record<string, unknown>>>();
const statusQueriesBeforeRebase = new Map<string, number>();

function addRebaseHandler(iid: string) {
mockGitLab.addMockHandler(
"put",
`/projects/${TEST_PROJECT_ID}/merge_requests/${iid}/rebase`,
(req, res) => {
rebaseBodies.set(iid, req.body);
statusQueriesBeforeRebase.set(iid, statusQueries.get(iid)?.length ?? 0);
res.status(202).json({ rebase_in_progress: true });
}
);
Expand Down Expand Up @@ -212,12 +214,18 @@ describe("rebase_merge_request tool", () => {
addRebaseHandler(iid);
}
addStatusHandler(MR_ENQUEUE_ONLY, [{ rebase_in_progress: true }]);
// The first GET of a waiting call reads the head before the rebase.
addStatusHandler(MR_SUCCESS, [
{ rebase_in_progress: false },
{ rebase_in_progress: true },
{ rebase_in_progress: false, sha: NEW_SHA },
]);
addStatusHandler(MR_FAILED, [{ rebase_in_progress: false, merge_error: REBASE_FAILED }]);
addStatusHandler(MR_FAILED, [
{ rebase_in_progress: false },
{ rebase_in_progress: false, merge_error: REBASE_FAILED },
]);
addStatusHandler(MR_TIMEOUT, [{ rebase_in_progress: true }]);
addStatusHandler(MR_FORBIDDEN, [{ rebase_in_progress: false }]);

mockGitLab.addMockHandler(
"put",
Expand Down Expand Up @@ -263,7 +271,7 @@ describe("rebase_merge_request tool", () => {
assert.deepStrictEqual(rebaseBodies.get(MR_SKIP_CI), { skip_ci: true });
});

test("rebase_merge_request polls until the rebase ends and returns the merge request", async () => {
test("rebase_merge_request polls until the rebase ends and reports that the head moved", async () => {
const result = await callTool(
"rebase_merge_request",
{ project_id: TEST_PROJECT_ID, merge_request_iid: MR_SUCCESS, poll_interval_seconds: 1 },
Expand All @@ -273,12 +281,15 @@ describe("rebase_merge_request tool", () => {
assert.strictEqual(result.rebase_in_progress, false);
assert.strictEqual(result.merge_error, null);
assert.strictEqual(result.sha, NEW_SHA);
assert.strictEqual(result.previous_sha, OLD_SHA);
assert.strictEqual(result.head_changed, true);
const queries = statusQueries.get(MR_SUCCESS) ?? [];
assert.strictEqual(queries.length, 2);
assert.strictEqual(queries.length, 3);
assert.strictEqual(statusQueriesBeforeRebase.get(MR_SUCCESS), 1);
assert.ok(queries.every(query => query.include_rebase_in_progress === "true"));
});

test("rebase_merge_request returns merge_error when the rebase fails", async () => {
test("rebase_merge_request returns merge_error and an unmoved head when the rebase fails", async () => {
const result = await callTool(
"rebase_merge_request",
{ project_id: TEST_PROJECT_ID, merge_request_iid: MR_FAILED },
Expand All @@ -287,6 +298,9 @@ describe("rebase_merge_request tool", () => {

assert.strictEqual(result.rebase_in_progress, false);
assert.strictEqual(result.merge_error, REBASE_FAILED);
assert.strictEqual(result.previous_sha, OLD_SHA);
assert.strictEqual(result.head_changed, false);
assert.strictEqual(statusQueries.get(MR_FAILED)?.length, 2);
});

test("rebase_merge_request reports a timeout while the rebase still runs", async () => {
Expand Down
2 changes: 1 addition & 1 deletion tools/tool-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const TOOL_GUIDANCE: Readonly<Record<string, string>> = {
unapprove_merge_request:
"Use this to remove the current user's approval from an existing merge request; use `merge_merge_request` only when you intend to merge. The operation changes review state and requires approval permission, and GitLab returns the updated result or an error when the request or approval is unavailable.",
rebase_merge_request:
"Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.",
"Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request plus `previous_sha` (its `sha` before the rebase) and `head_changed`. `head_changed: false` means that the head did not move; `merge_error` gives the reason when GitLab sends one. `merge_error` can keep a message from an earlier attempt, so it alone does not prove that this attempt failed. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.",
get_merge_request_approval_state:
"Use this to inspect approval rules and approvers before deciding whether a merge request can be merged; use `approve_merge_request` to change approval state. It is read-only and returns the approval-state response, while missing requests, unsupported GitLab versions, and permission failures are reported as errors.",
get_merge_request_conflicts:
Expand Down
Loading