From 920f7a6d01b6f367464e03e0c3da18a8d560240f Mon Sep 17 00:00:00 2001 From: vish288 <7909473+vish288@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:09:09 -0400 Subject: [PATCH] feat: report bugs as tool errors, keep the API envelope for expected failures Every tool body was the same template: try, optional _check_write(ctx), a run of `if x is not None: params[...] = x`, the call, and `except Exception as e: return _err(e)`. That last line returned a KeyError or TypeError in the body as a successful result with isError unset and no log line, so a broken tool was indistinguishable from a 404 to the caller (#76), and the template itself was 83 copies of the same 8 lines (#75). tool_result, applied under @mcp.tool, now owns that: - GitLabError subclasses (401/403/404, 409/422/429, read-only) take the _err envelope exactly as before; the contract tests in test_tool_contract.py assert the same key sets and wire requests. - Anything else is logged with _log.exception and re-raised as fastmcp ToolError with the type name in the message, which the client sees as isError: true. Two new tests pin both sides through Client(mcp): a respx RuntimeError side effect and a mocked 404. - write=True runs _check_write first; its failure is expected and takes the envelope. _params(**kw) drops None so the 90 unrolled guards become one keyword call per tool. The handful of truthiness guards (`if search:` and friends) now forward an explicit empty value instead of dropping it; the schema's min_length/ge constraints cover the realistic inputs. gitlab_merge_mr_sequence keeps its own GitLabError handler because its envelope has to say which MRs already merged; bugs still fall through to the decorator. #79 rides along: access_level is Literal["guest", "reporter", "developer", "maintainer", "owner"], so the schema rejects a bad value and both hand-rolled checks that answered on the success channel are gone. The existing "bogus level" test now expects the rejection and asserts no request was made. functools.wraps keeps the signature fastmcp introspects; the assembly test still counts 83 tools and every contract row still passes. servers/gitlab.py: 1039 -> 510 statements, coverage 100%. Tests 405 -> 408. Changelog: Unexpected server errors are now reported as MCP tool errors (isError: true) instead of successful results; expected API errors (401/403/404/409/429, read-only, validation) are unchanged. --- src/mcp_gitlab/servers/gitlab.py | 1454 ++++++++++++------------------ tests/unit/test_tool_contract.py | 31 + tests/unit/test_tools.py | 26 +- 3 files changed, 643 insertions(+), 868 deletions(-) diff --git a/src/mcp_gitlab/servers/gitlab.py b/src/mcp_gitlab/servers/gitlab.py index 36a9f08..8fc8e68 100644 --- a/src/mcp_gitlab/servers/gitlab.py +++ b/src/mcp_gitlab/servers/gitlab.py @@ -2,19 +2,27 @@ from __future__ import annotations +import functools import json import logging -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from importlib.metadata import version -from typing import Annotated, Any +from typing import Annotated, Any, Literal from fastmcp import Context, FastMCP +from fastmcp.exceptions import ToolError from pydantic import Field from ..client import GitLabClient from ..config import GitLabConfig -from ..exceptions import GitLabWriteDisabledError +from ..exceptions import ( + GitLabApiError, + GitLabAuthError, + GitLabError, + GitLabNotFoundError, + GitLabWriteDisabledError, +) _log = logging.getLogger(__name__) @@ -26,6 +34,13 @@ "owner": 50, } +# The schema rejects anything outside the five names, so the tools no longer +# validate this by hand and return the rejection as a successful result. +AccessLevel = Annotated[ + Literal["guest", "reporter", "developer", "maintainer", "owner"], + Field(description="Access level to grant"), +] + @asynccontextmanager async def lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: @@ -137,13 +152,6 @@ def _slim_job(j: dict) -> dict: def _err(error: Exception) -> str: detail: dict[str, Any] = {"error": str(error)} - from ..exceptions import ( - GitLabApiError, - GitLabAuthError, - GitLabNotFoundError, - GitLabWriteDisabledError, - ) - if isinstance(error, GitLabNotFoundError): detail["status_code"] = error.status_code detail["body"] = error.body @@ -166,6 +174,47 @@ def _err(error: Exception) -> str: return json.dumps(detail, indent=2, ensure_ascii=False) +def _params(**kw: Any) -> dict[str, Any]: + """Request params from tool arguments, with unset (None) ones dropped.""" + return {k: v for k, v in kw.items() if v is not None} + + +_Tool = Callable[..., Awaitable[str]] + + +def tool_result(fn: _Tool | None = None, *, write: bool = False) -> Any: + """Apply under ``@mcp.tool``. Expected failures become the JSON envelope; + anything else is a bug and is raised as a tool error. + + ``GitLabError`` covers what the API can legitimately answer (401/403/404, + 409/422/429, read-only). Every other exception used to be caught by the + same ``except Exception`` and returned as a successful result with no log + line, so a ``KeyError`` in a tool body looked like a 404 to the caller. + Now it is logged with its traceback and surfaces as ``isError: true``. + + ``write=True`` runs the read-only guard first; its failure is expected and + takes the envelope path like any other ``GitLabError``. + """ + + def wrap(f: _Tool) -> _Tool: + @functools.wraps(f) + async def inner(ctx: Context, *args: Any, **kwargs: Any) -> str: + try: + if write: + _check_write(ctx) + return await f(ctx, *args, **kwargs) + except GitLabError as e: + return _err(e) + except Exception as e: + _log.exception("%s failed", f.__name__) + msg = f"{type(e).__name__}: {e}" + raise ToolError(msg) from e + + return inner + + return wrap(fn) if fn is not None else wrap + + # ════════════════════════════════════════════════════════════════════ # Projects # ════════════════════════════════════════════════════════════════════ @@ -175,6 +224,7 @@ def _err(error: Exception) -> str: tags={"gitlab", "projects", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_project( ctx: Context, project_id: Annotated[ @@ -191,17 +241,14 @@ async def gitlab_get_project( Returns id, name, path_with_namespace, visibility, default_branch, web_url, description. """ - try: - data = await _get_client(ctx).get_project(project_id) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_project(project_id)) @mcp.tool( tags={"gitlab", "projects", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_project( ctx: Context, name: Annotated[str, Field(description="Project name", min_length=1)], @@ -216,31 +263,26 @@ async def gitlab_create_project( Returns the new project's id, name, path_with_namespace, web_url, and default settings. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"name": name} - if path is not None: - params["path"] = path - if namespace_id is not None: - params["namespace_id"] = namespace_id - if description is not None: - params["description"] = description - if visibility is not None: - params["visibility"] = visibility - if initialize_with_readme is not None: - params["initialize_with_readme"] = initialize_with_readme - if default_branch is not None: - params["default_branch"] = default_branch - data = await _get_client(ctx).create_project(params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_project( + _params( + name=name, + path=path, + namespace_id=namespace_id, + description=description, + visibility=visibility, + initialize_with_readme=initialize_with_readme, + default_branch=default_branch, + ) + ) + ) @mcp.tool( tags={"gitlab", "projects", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_project( ctx: Context, project_id: Annotated[ @@ -251,18 +293,15 @@ async def gitlab_delete_project( Returns a {status: deleted, project_id} confirmation. """ - try: - _check_write(ctx) - await _get_client(ctx).delete_project(project_id) - return _ok({"status": "deleted", "project_id": project_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_project(project_id) + return _ok({"status": "deleted", "project_id": project_id}) @mcp.tool( tags={"gitlab", "projects", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_project_merge_settings( ctx: Context, project_id: Annotated[ @@ -286,25 +325,18 @@ async def gitlab_update_project_merge_settings( Returns the updated project object. """ - try: - _check_write(ctx) - params: dict[str, Any] = {} - if only_allow_merge_if_pipeline_succeeds is not None: - params["only_allow_merge_if_pipeline_succeeds"] = only_allow_merge_if_pipeline_succeeds - if only_allow_merge_if_all_discussions_are_resolved is not None: - params["only_allow_merge_if_all_discussions_are_resolved"] = ( - only_allow_merge_if_all_discussions_are_resolved - ) - if remove_source_branch_after_merge is not None: - params["remove_source_branch_after_merge"] = remove_source_branch_after_merge - if squash_option is not None: - params["squash_option"] = squash_option - if merge_method is not None: - params["merge_method"] = merge_method - data = await _get_client(ctx).update_project(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_project( + project_id, + _params( + only_allow_merge_if_pipeline_succeeds=only_allow_merge_if_pipeline_succeeds, + only_allow_merge_if_all_discussions_are_resolved=only_allow_merge_if_all_discussions_are_resolved, + remove_source_branch_after_merge=remove_source_branch_after_merge, + squash_option=squash_option, + merge_method=merge_method, + ), + ) + ) # ════════════════════════════════════════════════════════════════════ @@ -316,6 +348,7 @@ async def gitlab_update_project_merge_settings( tags={"gitlab", "approvals", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_project_approvals( ctx: Context, project_id: Annotated[ @@ -326,17 +359,14 @@ async def gitlab_get_project_approvals( Returns approvals_before_merge, reset_approvals_on_push, and self-approval rules. """ - try: - data = await _get_client(ctx).get_project_approvals(project_id) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_project_approvals(project_id)) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_project_approvals( ctx: Context, project_id: Annotated[ @@ -359,33 +389,25 @@ async def gitlab_update_project_approvals( ] = None, ) -> str: """Update project-level approval settings. Returns the updated approval configuration.""" - try: - _check_write(ctx) - params: dict[str, Any] = {} - if approvals_before_merge is not None: - params["approvals_before_merge"] = approvals_before_merge - if reset_approvals_on_push is not None: - params["reset_approvals_on_push"] = reset_approvals_on_push - if disable_overriding_approvers_per_merge_request is not None: - params["disable_overriding_approvers_per_merge_request"] = ( - disable_overriding_approvers_per_merge_request - ) - if merge_requests_author_approval is not None: - params["merge_requests_author_approval"] = merge_requests_author_approval - if merge_requests_disable_committers_approval is not None: - params["merge_requests_disable_committers_approval"] = ( - merge_requests_disable_committers_approval - ) - data = await _get_client(ctx).update_project_approvals(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_project_approvals( + project_id, + _params( + approvals_before_merge=approvals_before_merge, + reset_approvals_on_push=reset_approvals_on_push, + disable_overriding_approvers_per_merge_request=disable_overriding_approvers_per_merge_request, + merge_requests_author_approval=merge_requests_author_approval, + merge_requests_disable_committers_approval=merge_requests_disable_committers_approval, + ), + ) + ) @mcp.tool( tags={"gitlab", "approvals", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_project_approval_rules( ctx: Context, project_id: Annotated[ @@ -396,17 +418,15 @@ async def gitlab_list_project_approval_rules( Returns rules with id, name, approvals_required, and eligible approvers/groups. """ - try: - data = await _get_client(ctx).list_project_approval_rules(project_id) - return _paginated(data) - except Exception as e: - return _err(e) + data = await _get_client(ctx).list_project_approval_rules(project_id) + return _paginated(data) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_project_approval_rule( ctx: Context, project_id: Annotated[ @@ -421,26 +441,24 @@ async def gitlab_create_project_approval_rule( Returns the new rule's id, name, approvals_required, and target users/groups. """ - try: - _check_write(ctx) - params: dict[str, Any] = { - "name": name, - "approvals_required": approvals_required, - } - if user_ids: - params["user_ids"] = user_ids - if group_ids: - params["group_ids"] = group_ids - data = await _get_client(ctx).create_project_approval_rule(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_project_approval_rule( + project_id, + _params( + name=name, + approvals_required=approvals_required, + user_ids=user_ids, + group_ids=group_ids, + ), + ) + ) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_project_approval_rule( ctx: Context, project_id: Annotated[ @@ -455,27 +473,25 @@ async def gitlab_update_project_approval_rule( group_ids: Annotated[list[int] | None, Field(description="Group IDs for the rule")] = None, ) -> str: """Update a project-level approval rule. Returns the updated rule object.""" - try: - _check_write(ctx) - params: dict[str, Any] = {} - if name is not None: - params["name"] = name - if approvals_required is not None: - params["approvals_required"] = approvals_required - if user_ids is not None: - params["user_ids"] = user_ids - if group_ids is not None: - params["group_ids"] = group_ids - data = await _get_client(ctx).update_project_approval_rule(project_id, rule_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_project_approval_rule( + project_id, + rule_id, + _params( + name=name, + approvals_required=approvals_required, + user_ids=user_ids, + group_ids=group_ids, + ), + ) + ) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_project_approval_rule( ctx: Context, project_id: Annotated[ @@ -484,12 +500,8 @@ async def gitlab_delete_project_approval_rule( rule_id: Annotated[int, Field(description="Approval rule ID")], ) -> str: """Delete a project-level approval rule. Returns a {status: deleted, rule_id} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_project_approval_rule(project_id, rule_id) - return _ok({"status": "deleted", "rule_id": rule_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_project_approval_rule(project_id, rule_id) + return _ok({"status": "deleted", "rule_id": rule_id}) # ════════════════════════════════════════════════════════════════════ @@ -501,6 +513,7 @@ async def gitlab_delete_project_approval_rule( tags={"gitlab", "approvals", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mr_approval_rules( ctx: Context, project_id: Annotated[ @@ -512,17 +525,15 @@ async def gitlab_list_mr_approval_rules( Returns rules with id, name, approvals_required, and approvers. """ - try: - data = await _get_client(ctx).list_mr_approval_rules(project_id, mr_iid) - return _paginated(data) - except Exception as e: - return _err(e) + data = await _get_client(ctx).list_mr_approval_rules(project_id, mr_iid) + return _paginated(data) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_mr_approval_rule( ctx: Context, project_id: Annotated[ @@ -538,26 +549,25 @@ async def gitlab_create_mr_approval_rule( Returns the new rule's id, name, approvals_required, and approvers. """ - try: - _check_write(ctx) - params: dict[str, Any] = { - "name": name, - "approvals_required": approvals_required, - } - if user_ids: - params["user_ids"] = user_ids - if group_ids: - params["group_ids"] = group_ids - data = await _get_client(ctx).create_mr_approval_rule(project_id, mr_iid, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_mr_approval_rule( + project_id, + mr_iid, + _params( + name=name, + approvals_required=approvals_required, + user_ids=user_ids, + group_ids=group_ids, + ), + ) + ) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_mr_approval_rule( ctx: Context, project_id: Annotated[ @@ -571,27 +581,26 @@ async def gitlab_update_mr_approval_rule( group_ids: Annotated[list[int] | None, Field(description="Group IDs")] = None, ) -> str: """Update a merge-request-level approval rule. Returns the updated rule object.""" - try: - _check_write(ctx) - params: dict[str, Any] = {} - if name is not None: - params["name"] = name - if approvals_required is not None: - params["approvals_required"] = approvals_required - if user_ids is not None: - params["user_ids"] = user_ids - if group_ids is not None: - params["group_ids"] = group_ids - data = await _get_client(ctx).update_mr_approval_rule(project_id, mr_iid, rule_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_mr_approval_rule( + project_id, + mr_iid, + rule_id, + _params( + name=name, + approvals_required=approvals_required, + user_ids=user_ids, + group_ids=group_ids, + ), + ) + ) @mcp.tool( tags={"gitlab", "approvals", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_mr_approval_rule( ctx: Context, project_id: Annotated[ @@ -604,12 +613,8 @@ async def gitlab_delete_mr_approval_rule( Returns a {status: deleted, rule_id} confirmation. """ - try: - _check_write(ctx) - await _get_client(ctx).delete_mr_approval_rule(project_id, mr_iid, rule_id) - return _ok({"status": "deleted", "rule_id": rule_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_mr_approval_rule(project_id, mr_iid, rule_id) + return _ok({"status": "deleted", "rule_id": rule_id}) # ════════════════════════════════════════════════════════════════════ @@ -621,6 +626,7 @@ async def gitlab_delete_mr_approval_rule( tags={"gitlab", "groups", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_groups( ctx: Context, search: Annotated[str | None, Field(description="Search by name")] = None, @@ -633,23 +639,17 @@ async def gitlab_list_groups( Returns id, name, full_path, parent_id, visibility, web_url per group. """ - try: - params: dict[str, Any] = {} - if search: - params["search"] = search - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_groups(params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_groups( + _params(search=search, per_page=per_page, page=page) + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "groups", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_group( ctx: Context, group_id: Annotated[str, Field(description="Group ID or URL-encoded path", min_length=1)], @@ -658,48 +658,37 @@ async def gitlab_get_group( Returns id, name, full_path, visibility, web_url, and subgroup/membership counts. """ - try: - data = await _get_client(ctx).get_group(group_id) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_group(group_id)) @mcp.tool( tags={"gitlab", "groups", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_share_project_with_group( ctx: Context, project_id: Annotated[ str, Field(description="Project ID, path, or full GitLab URL", min_length=1) ], group_id: Annotated[int, Field(description="Group ID to share with")], - access_level: Annotated[ - str, Field(description="guest, reporter, developer, maintainer, or owner") - ], + access_level: AccessLevel, ) -> str: """Grant a group access to a project at the given access level. Returns a {status: shared, project_id, group_id} confirmation. """ - try: - _check_write(ctx) - level = ACCESS_LEVELS.get(access_level.lower()) - if level is None: - return _ok( - {"error": f"Invalid access level: {access_level}. Use: {', '.join(ACCESS_LEVELS)}"} - ) - await _get_client(ctx).share_project_with_group(project_id, group_id, level) - return _ok({"status": "shared", "project_id": project_id, "group_id": group_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).share_project_with_group( + project_id, group_id, ACCESS_LEVELS[access_level] + ) + return _ok({"status": "shared", "project_id": project_id, "group_id": group_id}) @mcp.tool( tags={"gitlab", "groups", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_unshare_project_with_group( ctx: Context, project_id: Annotated[ @@ -711,59 +700,44 @@ async def gitlab_unshare_project_with_group( Returns a {status: unshared, project_id, group_id} confirmation. """ - try: - _check_write(ctx) - await _get_client(ctx).unshare_project_with_group(project_id, group_id) - return _ok({"status": "unshared", "project_id": project_id, "group_id": group_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).unshare_project_with_group(project_id, group_id) + return _ok({"status": "unshared", "project_id": project_id, "group_id": group_id}) @mcp.tool( tags={"gitlab", "groups", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_share_group_with_group( ctx: Context, target_group_id: Annotated[str, Field(description="Target group ID or path")], source_group_id: Annotated[int, Field(description="Source group ID to share")], - access_level: Annotated[ - str, Field(description="guest, reporter, developer, maintainer, or owner") - ], + access_level: AccessLevel, ) -> str: """Grant one group access to another group at the given access level. Returns the share record. """ - try: - _check_write(ctx) - level = ACCESS_LEVELS.get(access_level.lower()) - if level is None: - return _ok( - {"error": f"Invalid access level: {access_level}. Use: {', '.join(ACCESS_LEVELS)}"} - ) - await _get_client(ctx).share_group_with_group(target_group_id, source_group_id, level) - return _ok({"status": "shared"}) - except Exception as e: - return _err(e) + await _get_client(ctx).share_group_with_group( + target_group_id, source_group_id, ACCESS_LEVELS[access_level] + ) + return _ok({"status": "shared"}) @mcp.tool( tags={"gitlab", "groups", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_unshare_group_with_group( ctx: Context, target_group_id: Annotated[str, Field(description="Target group ID or path")], source_group_id: Annotated[int, Field(description="Source group ID to remove")], ) -> str: """Revoke a group-to-group share. Returns a {status: unshared} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).unshare_group_with_group(target_group_id, source_group_id) - return _ok({"status": "unshared"}) - except Exception as e: - return _err(e) + await _get_client(ctx).unshare_group_with_group(target_group_id, source_group_id) + return _ok({"status": "unshared"}) # ════════════════════════════════════════════════════════════════════ @@ -775,6 +749,7 @@ async def gitlab_unshare_group_with_group( tags={"gitlab", "branches", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_branches( ctx: Context, project_id: Annotated[ @@ -790,23 +765,17 @@ async def gitlab_list_branches( Returns name, commit sha, protected, default, and merged flags per branch. """ - try: - params: dict[str, Any] = {} - if search: - params["search"] = search - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_branches(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_branches( + project_id, _params(search=search, per_page=per_page, page=page) + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "branches", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_branch( ctx: Context, project_id: Annotated[ @@ -819,18 +788,14 @@ async def gitlab_create_branch( Returns the new branch's name, commit sha, and protection status. """ - try: - _check_write(ctx) - data = await _get_client(ctx).create_branch(project_id, branch_name, ref) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).create_branch(project_id, branch_name, ref)) @mcp.tool( tags={"gitlab", "branches", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_branch( ctx: Context, project_id: Annotated[ @@ -839,12 +804,8 @@ async def gitlab_delete_branch( branch_name: Annotated[str, Field(description="Branch name to delete", min_length=1)], ) -> str: """Delete a branch from a project. Returns a {status: deleted, branch} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_branch(project_id, branch_name) - return _ok({"status": "deleted", "branch": branch_name}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_branch(project_id, branch_name) + return _ok({"status": "deleted", "branch": branch_name}) # ════════════════════════════════════════════════════════════════════ @@ -856,6 +817,7 @@ async def gitlab_delete_branch( tags={"gitlab", "commits", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_commits( ctx: Context, project_id: Annotated[ @@ -874,29 +836,20 @@ async def gitlab_list_commits( Returns id, short_id, title, author_name, authored_date, web_url per commit. """ - try: - params: dict[str, Any] = {} - if ref_name: - params["ref_name"] = ref_name - if since: - params["since"] = since - if until: - params["until"] = until - if path: - params["path"] = path - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_commits(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_commits( + project_id, + _params( + ref_name=ref_name, since=since, until=until, path=path, per_page=per_page, page=page + ), + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "commits", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_commit( ctx: Context, project_id: Annotated[ @@ -910,21 +863,19 @@ async def gitlab_get_commit( Returns id, short_id, title, message, author, committed_date, web_url (and diffs when include_diff=true). """ - try: - client = _get_client(ctx) - commit = await client.get_commit(project_id, sha) - if include_diff: - diff = await client.get_commit_diff(project_id, sha) - commit["diffs"] = diff - return _ok(commit) - except Exception as e: - return _err(e) + client = _get_client(ctx) + commit = await client.get_commit(project_id, sha) + if include_diff: + diff = await client.get_commit_diff(project_id, sha) + commit["diffs"] = diff + return _ok(commit) @mcp.tool( tags={"gitlab", "commits", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_commit( ctx: Context, project_id: Annotated[ @@ -947,25 +898,24 @@ async def gitlab_create_commit( Returns the new commit's id, short_id, title, and parent_ids. """ - try: - _check_write(ctx) - params: dict[str, Any] = { - "branch": branch, - "commit_message": commit_message, - "actions": actions, - } - if start_branch: - params["start_branch"] = start_branch - data = await _get_client(ctx).create_commit(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_commit( + project_id, + _params( + branch=branch, + commit_message=commit_message, + actions=actions, + start_branch=start_branch, + ), + ) + ) @mcp.tool( tags={"gitlab", "commits", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_compare( ctx: Context, project_id: Annotated[ @@ -980,11 +930,7 @@ async def gitlab_compare( Returns commits, diffs, compare_timeout, and compare_same_ref flags. """ - try: - data = await _get_client(ctx).compare(project_id, from_ref, to_ref) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).compare(project_id, from_ref, to_ref)) # ════════════════════════════════════════════════════════════════════ @@ -996,6 +942,7 @@ async def gitlab_compare( tags={"gitlab", "merge_requests", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mrs( ctx: Context, project_id: Annotated[ @@ -1016,33 +963,27 @@ async def gitlab_list_mrs( Returns iid, title, state, source_branch, target_branch, author, web_url per MR. """ - try: - params: dict[str, Any] = {} - if state: - params["state"] = state - if scope: - params["scope"] = scope - if source_branch: - params["source_branch"] = source_branch - if target_branch: - params["target_branch"] = target_branch - if search: - params["search"] = search - if labels: - params["labels"] = labels - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_merge_requests(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_merge_requests( + project_id, + _params( + state=state, + scope=scope, + source_branch=source_branch, + target_branch=target_branch, + search=search, + labels=labels, + per_page=per_page, + page=page, + ), + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "merge_requests", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_mr( ctx: Context, project_id: Annotated[ @@ -1054,17 +995,14 @@ async def gitlab_get_mr( Returns title, state, source/target branches, author, diff_refs, and merge status. """ - try: - data = await _get_client(ctx).get_merge_request(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_merge_request(project_id, mr_iid)) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_mr( ctx: Context, project_id: Annotated[ @@ -1085,33 +1023,28 @@ async def gitlab_create_mr( Returns the new MR's iid, title, state, source_branch, target_branch, and web_url. """ - try: - _check_write(ctx) - params: dict[str, Any] = { - "source_branch": source_branch, - "target_branch": target_branch, - "title": title, - } - if description is not None: - params["description"] = description - if draft is not None: - params["draft"] = draft - if squash is not None: - params["squash"] = squash - if remove_source_branch is not None: - params["remove_source_branch"] = remove_source_branch - if labels is not None: - params["labels"] = labels - data = await _get_client(ctx).create_merge_request(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_merge_request( + project_id, + _params( + source_branch=source_branch, + target_branch=target_branch, + title=title, + description=description, + draft=draft, + squash=squash, + remove_source_branch=remove_source_branch, + labels=labels, + ), + ) + ) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_mr( ctx: Context, project_id: Annotated[ @@ -1133,35 +1066,29 @@ async def gitlab_update_mr( Returns the updated MR object. """ - try: - _check_write(ctx) - params: dict[str, Any] = {} - if title is not None: - params["title"] = title - if description is not None: - params["description"] = description - if target_branch is not None: - params["target_branch"] = target_branch - if labels is not None: - params["labels"] = labels - if squash is not None: - params["squash"] = squash - if remove_source_branch is not None: - params["remove_source_branch"] = remove_source_branch - if draft is not None: - params["draft"] = draft - if state_event is not None: - params["state_event"] = state_event - data = await _get_client(ctx).update_merge_request(project_id, mr_iid, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_merge_request( + project_id, + mr_iid, + _params( + title=title, + description=description, + target_branch=target_branch, + labels=labels, + squash=squash, + remove_source_branch=remove_source_branch, + draft=draft, + state_event=state_event, + ), + ) + ) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_merge_mr( ctx: Context, project_id: Annotated[ @@ -1186,29 +1113,26 @@ async def gitlab_merge_mr( Returns the merged MR with state=merged and merge_commit_sha. """ - try: - _check_write(ctx) - params: dict[str, Any] = {} - if squash is not None: - params["squash"] = squash - if delete_source_branch is not None: - params["should_remove_source_branch"] = delete_source_branch - if merge_commit_message is not None: - params["merge_commit_message"] = merge_commit_message - if squash_commit_message is not None: - params["squash_commit_message"] = squash_commit_message - if merge_when_pipeline_succeeds is not None: - params["merge_when_pipeline_succeeds"] = merge_when_pipeline_succeeds - data = await _get_client(ctx).merge_merge_request(project_id, mr_iid, params or None) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).merge_merge_request( + project_id, + mr_iid, + _params( + squash=squash, + should_remove_source_branch=delete_source_branch, + merge_commit_message=merge_commit_message, + squash_commit_message=squash_commit_message, + merge_when_pipeline_succeeds=merge_when_pipeline_succeeds, + ), + ) + ) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result async def gitlab_merge_mr_sequence( ctx: Context, project_id: Annotated[ @@ -1228,18 +1152,17 @@ async def gitlab_merge_mr_sequence( Returns a per-MR result list with merged/failed status and any error. """ + # Keeps its own expected-error handler: the envelope must say which MRs + # already merged before the failure. Bugs still fall through to tool_result. merged: list[int] = [] try: _check_write(ctx) client = _get_client(ctx) - params: dict[str, Any] = {} - if squash is not None: - params["squash"] = squash - if delete_source_branch is not None: - params["should_remove_source_branch"] = delete_source_branch - if merge_when_pipeline_succeeds is not None: - params["merge_when_pipeline_succeeds"] = merge_when_pipeline_succeeds - + params = _params( + squash=squash, + should_remove_source_branch=delete_source_branch, + merge_when_pipeline_succeeds=merge_when_pipeline_succeeds, + ) for iid in mr_iids: if require_mergeable_status: mr = await client.get_merge_request(project_id, iid) @@ -1251,11 +1174,10 @@ async def gitlab_merge_mr_sequence( "merged_so_far": merged, } ) - await client.merge_merge_request(project_id, iid, params or None) + await client.merge_merge_request(project_id, iid, params) merged.append(iid) - return _ok({"status": "all_merged", "merged": merged}) - except Exception as e: + except GitLabError as e: detail = json.loads(_err(e)) detail["merged_so_far"] = merged return _ok(detail) @@ -1265,6 +1187,7 @@ async def gitlab_merge_mr_sequence( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_rebase_mr( ctx: Context, project_id: Annotated[ @@ -1277,18 +1200,14 @@ async def gitlab_rebase_mr( Returns {rebase_in_progress: true}. """ - try: - _check_write(ctx) - data = await _get_client(ctx).rebase_merge_request(project_id, mr_iid, skip_ci) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).rebase_merge_request(project_id, mr_iid, skip_ci)) @mcp.tool( tags={"gitlab", "merge_requests", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_mr_changes( ctx: Context, project_id: Annotated[ @@ -1297,11 +1216,7 @@ async def gitlab_mr_changes( mr_iid: Annotated[int, Field(description="Merge request IID")], ) -> str: """Get file changes of a merge request. Returns list of diffs with old/new paths and content.""" - try: - data = await _get_client(ctx).get_merge_request_changes(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_merge_request_changes(project_id, mr_iid)) # ════════════════════════════════════════════════════════════════════ @@ -1313,6 +1228,7 @@ async def gitlab_mr_changes( tags={"gitlab", "notes", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mr_notes( ctx: Context, project_id: Annotated[ @@ -1331,19 +1247,17 @@ async def gitlab_list_mr_notes( system notes therefore returns count 0 with has_more true -- keep following next_page rather than concluding the MR has no comments. """ - try: - data, next_page = await _get_client(ctx).list_mr_notes(project_id, mr_iid, page) - if not include_system: - data = [n for n in data if not n.get("system", False)] - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_mr_notes(project_id, mr_iid, page) + if not include_system: + data = [n for n in data if not n.get("system", False)] + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "notes", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_add_mr_note( ctx: Context, project_id: Annotated[ @@ -1359,18 +1273,14 @@ async def gitlab_add_mr_note( Returns the new note's id, body, author, and created_at. """ - try: - _check_write(ctx) - data = await _get_client(ctx).add_mr_note(project_id, mr_iid, body, internal) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).add_mr_note(project_id, mr_iid, body, internal)) @mcp.tool( tags={"gitlab", "notes", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_mr_note( ctx: Context, project_id: Annotated[ @@ -1380,18 +1290,15 @@ async def gitlab_delete_mr_note( note_id: Annotated[int, Field(description="Note ID to delete")], ) -> str: """Delete a comment from a merge request. Returns a {status: deleted, note_id} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_mr_note(project_id, mr_iid, note_id) - return _ok({"status": "deleted", "note_id": note_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_mr_note(project_id, mr_iid, note_id) + return _ok({"status": "deleted", "note_id": note_id}) @mcp.tool( tags={"gitlab", "notes", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_mr_note( ctx: Context, project_id: Annotated[ @@ -1405,18 +1312,14 @@ async def gitlab_update_mr_note( Returns the updated note's id, body, author, and updated_at. """ - try: - _check_write(ctx) - data = await _get_client(ctx).update_mr_note(project_id, mr_iid, note_id, body) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).update_mr_note(project_id, mr_iid, note_id, body)) @mcp.tool( tags={"gitlab", "notes", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_award_emoji( ctx: Context, project_id: Annotated[ @@ -1427,18 +1330,14 @@ async def gitlab_award_emoji( emoji: Annotated[str, Field(description="Emoji name (e.g. thumbsup, 100, eyes)", min_length=1)], ) -> str: """Add an emoji reaction to an MR note. Returns the new award's id, name, and user.""" - try: - _check_write(ctx) - data = await _get_client(ctx).award_emoji(project_id, mr_iid, note_id, emoji) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).award_emoji(project_id, mr_iid, note_id, emoji)) @mcp.tool( tags={"gitlab", "notes", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_remove_emoji( ctx: Context, project_id: Annotated[ @@ -1449,12 +1348,8 @@ async def gitlab_remove_emoji( award_id: Annotated[int, Field(description="Award emoji ID to remove")], ) -> str: """Remove an emoji reaction from an MR note. Returns a {status: removed} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_award_emoji(project_id, mr_iid, note_id, award_id) - return _ok({"status": "removed", "award_id": award_id}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_award_emoji(project_id, mr_iid, note_id, award_id) + return _ok({"status": "removed", "award_id": award_id}) # ════════════════════════════════════════════════════════════════════ @@ -1466,6 +1361,7 @@ async def gitlab_remove_emoji( tags={"gitlab", "discussions", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mr_discussions( ctx: Context, project_id: Annotated[ @@ -1481,23 +1377,21 @@ async def gitlab_list_mr_discussions( `count` is post-filter while `has_more` describes the unfiltered server page, so a page of only system threads returns count 0 with has_more true. """ - try: - data, next_page = await _get_client(ctx).list_mr_discussions(project_id, mr_iid, page) - # Filter out system-only discussions - filtered = [] - for d in data: - notes = d.get("notes", []) - if any(not n.get("system", False) for n in notes): - filtered.append(d) - return _paginated(filtered, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_mr_discussions(project_id, mr_iid, page) + # Filter out system-only discussions + filtered = [] + for d in data: + notes = d.get("notes", []) + if any(not n.get("system", False) for n in notes): + filtered.append(d) + return _paginated(filtered, next_page) @mcp.tool( tags={"gitlab", "discussions", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_mr_discussion( ctx: Context, project_id: Annotated[ @@ -1524,46 +1418,42 @@ async def gitlab_create_mr_discussion( For inline comments, provide diff_refs and line info. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"body": body} - - # Build position for inline comments - if base_sha and head_sha and start_sha and new_path: - position: dict[str, Any] = { - "base_sha": base_sha, - "start_sha": start_sha, - "head_sha": head_sha, - "position_type": "text", - "new_path": new_path, - "old_path": old_path or new_path, + params: dict[str, Any] = {"body": body} + + # Build position for inline comments + if base_sha and head_sha and start_sha and new_path: + position: dict[str, Any] = { + "base_sha": base_sha, + "start_sha": start_sha, + "head_sha": head_sha, + "position_type": "text", + "new_path": new_path, + "old_path": old_path or new_path, + } + if new_line is not None: + position["new_line"] = new_line + if old_line is not None: + position["old_line"] = old_line + + # Multi-line range + if line_range_start_line is not None and line_range_end_line is not None: + range_type = line_range_type or "new" + line_key = "new_line" if range_type == "new" else "old_line" + position["line_range"] = { + "start": {"type": range_type, line_key: line_range_start_line}, + "end": {"type": range_type, line_key: line_range_end_line}, } - if new_line is not None: - position["new_line"] = new_line - if old_line is not None: - position["old_line"] = old_line - # Multi-line range - if line_range_start_line is not None and line_range_end_line is not None: - range_type = line_range_type or "new" - line_key = "new_line" if range_type == "new" else "old_line" - position["line_range"] = { - "start": {"type": range_type, line_key: line_range_start_line}, - "end": {"type": range_type, line_key: line_range_end_line}, - } + params["position"] = position - params["position"] = position - - data = await _get_client(ctx).create_mr_discussion(project_id, mr_iid, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).create_mr_discussion(project_id, mr_iid, params)) @mcp.tool( tags={"gitlab", "discussions", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_reply_to_discussion( ctx: Context, project_id: Annotated[ @@ -1577,18 +1467,14 @@ async def gitlab_reply_to_discussion( Returns the new note's id, body, author, and created_at. """ - try: - _check_write(ctx) - data = await _get_client(ctx).reply_to_discussion(project_id, mr_iid, discussion_id, body) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).reply_to_discussion(project_id, mr_iid, discussion_id, body)) @mcp.tool( tags={"gitlab", "discussions", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_resolve_discussion( ctx: Context, project_id: Annotated[ @@ -1602,14 +1488,8 @@ async def gitlab_resolve_discussion( Returns the updated discussion with its resolved flag and notes. """ - try: - _check_write(ctx) - data = await _get_client(ctx).resolve_discussion( - project_id, mr_iid, discussion_id, resolved - ) - return _ok(data) - except Exception as e: - return _err(e) + data = await _get_client(ctx).resolve_discussion(project_id, mr_iid, discussion_id, resolved) + return _ok(data) # ════════════════════════════════════════════════════════════════════ @@ -1621,6 +1501,7 @@ async def gitlab_resolve_discussion( tags={"gitlab", "merge_requests", "approvals", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": False}, ) +@tool_result(write=True) async def gitlab_approve_mr( ctx: Context, project_id: Annotated[ @@ -1636,18 +1517,14 @@ async def gitlab_approve_mr( Returns the updated approval state (approved_by, approvals_left). """ - try: - _check_write(ctx) - data = await _get_client(ctx).approve_merge_request(project_id, mr_iid, sha) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).approve_merge_request(project_id, mr_iid, sha)) @mcp.tool( tags={"gitlab", "merge_requests", "approvals", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": False}, ) +@tool_result(write=True) async def gitlab_unapprove_mr( ctx: Context, project_id: Annotated[ @@ -1659,18 +1536,14 @@ async def gitlab_unapprove_mr( Returns the updated approval state. """ - try: - _check_write(ctx) - data = await _get_client(ctx).unapprove_merge_request(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).unapprove_merge_request(project_id, mr_iid)) @mcp.tool( tags={"gitlab", "merge_requests", "approvals", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_mr_approvals( ctx: Context, project_id: Annotated[ @@ -1682,17 +1555,14 @@ async def gitlab_get_mr_approvals( Returns approved_by, approvals_required, approvals_left, and matching rules. """ - try: - data = await _get_client(ctx).get_mr_approvals(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_mr_approvals(project_id, mr_iid)) @mcp.tool( tags={"gitlab", "merge_requests", "pipelines", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mr_pipelines( ctx: Context, project_id: Annotated[ @@ -1706,17 +1576,15 @@ async def gitlab_list_mr_pipelines( Returns id, status, ref, sha, source, web_url per pipeline. """ - try: - data, next_page = await _get_client(ctx).list_mr_pipelines(project_id, mr_iid, page) - return _paginated([_slim_pipeline(p) for p in data] if slim else data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_mr_pipelines(project_id, mr_iid, page) + return _paginated([_slim_pipeline(p) for p in data] if slim else data, next_page) @mcp.tool( tags={"gitlab", "merge_requests", "commits", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_mr_commits( ctx: Context, project_id: Annotated[ @@ -1729,17 +1597,15 @@ async def gitlab_list_mr_commits( Returns id, short_id, title, author_name, authored_date per commit. """ - try: - data, next_page = await _get_client(ctx).list_mr_commits(project_id, mr_iid, page) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_mr_commits(project_id, mr_iid, page) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": False}, ) +@tool_result(write=True) async def gitlab_subscribe_mr( ctx: Context, project_id: Annotated[ @@ -1751,18 +1617,14 @@ async def gitlab_subscribe_mr( Returns the updated MR object. """ - try: - _check_write(ctx) - data = await _get_client(ctx).subscribe_mr(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).subscribe_mr(project_id, mr_iid)) @mcp.tool( tags={"gitlab", "merge_requests", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": False}, ) +@tool_result(write=True) async def gitlab_unsubscribe_mr( ctx: Context, project_id: Annotated[ @@ -1774,12 +1636,7 @@ async def gitlab_unsubscribe_mr( Returns the updated MR object. """ - try: - _check_write(ctx) - data = await _get_client(ctx).unsubscribe_mr(project_id, mr_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).unsubscribe_mr(project_id, mr_iid)) # ════════════════════════════════════════════════════════════════════ @@ -1791,6 +1648,7 @@ async def gitlab_unsubscribe_mr( tags={"gitlab", "pipelines", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_pipelines( ctx: Context, project_id: Annotated[ @@ -1811,27 +1669,17 @@ async def gitlab_list_pipelines( page: Annotated[int, Field(description="Page number (follow next_page to continue)", ge=1)] = 1, ) -> str: """List pipelines for a project. Returns id, status, ref, source, timing, web_url.""" - try: - params: dict[str, Any] = {} - if ref: - params["ref"] = ref - if status: - params["status"] = status - if source: - params["source"] = source - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_pipelines(project_id, params or None) - return _paginated([_slim_pipeline(p) for p in data] if slim else data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_pipelines( + project_id, _params(ref=ref, status=status, source=source, per_page=per_page, page=page) + ) + return _paginated([_slim_pipeline(p) for p in data] if slim else data, next_page) @mcp.tool( tags={"gitlab", "pipelines", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_pipeline( ctx: Context, project_id: Annotated[ @@ -1846,23 +1694,21 @@ async def gitlab_get_pipeline( Returns id, status, ref, timing, web_url. Jobs include id, name, stage, status, timing, failure_reason, web_url. """ - try: - client = _get_client(ctx) - pipeline = await client.get_pipeline(project_id, pipeline_id) - if slim: - pipeline = _slim_pipeline(pipeline) - if include_jobs: - jobs, _ = await client.list_pipeline_jobs(project_id, pipeline_id) - pipeline["jobs"] = [_slim_job(j) for j in jobs] if slim else jobs - return _ok(pipeline) - except Exception as e: - return _err(e) + client = _get_client(ctx) + pipeline = await client.get_pipeline(project_id, pipeline_id) + if slim: + pipeline = _slim_pipeline(pipeline) + if include_jobs: + jobs, _ = await client.list_pipeline_jobs(project_id, pipeline_id) + pipeline["jobs"] = [_slim_job(j) for j in jobs] if slim else jobs + return _ok(pipeline) @mcp.tool( tags={"gitlab", "pipelines", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_pipeline( ctx: Context, project_id: Annotated[ @@ -1878,18 +1724,14 @@ async def gitlab_create_pipeline( Returns the new pipeline's id, status, ref, sha, source, web_url. """ - try: - _check_write(ctx) - data = await _get_client(ctx).create_pipeline(project_id, ref, variables) - return _ok(_slim_pipeline(data)) - except Exception as e: - return _err(e) + return _ok(_slim_pipeline(await _get_client(ctx).create_pipeline(project_id, ref, variables))) @mcp.tool( tags={"gitlab", "pipelines", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_retry_pipeline( ctx: Context, project_id: Annotated[ @@ -1901,18 +1743,14 @@ async def gitlab_retry_pipeline( Returns the updated pipeline with status and timing. """ - try: - _check_write(ctx) - data = await _get_client(ctx).retry_pipeline(project_id, pipeline_id) - return _ok(_slim_pipeline(data)) - except Exception as e: - return _err(e) + return _ok(_slim_pipeline(await _get_client(ctx).retry_pipeline(project_id, pipeline_id))) @mcp.tool( tags={"gitlab", "pipelines", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_cancel_pipeline( ctx: Context, project_id: Annotated[ @@ -1921,12 +1759,7 @@ async def gitlab_cancel_pipeline( pipeline_id: Annotated[int, Field(description="Pipeline ID")], ) -> str: """Cancel a running pipeline. Returns the updated pipeline with status=canceled.""" - try: - _check_write(ctx) - data = await _get_client(ctx).cancel_pipeline(project_id, pipeline_id) - return _ok(_slim_pipeline(data)) - except Exception as e: - return _err(e) + return _ok(_slim_pipeline(await _get_client(ctx).cancel_pipeline(project_id, pipeline_id))) # ════════════════════════════════════════════════════════════════════ @@ -1938,6 +1771,7 @@ async def gitlab_cancel_pipeline( tags={"gitlab", "jobs", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_retry_job( ctx: Context, project_id: Annotated[ @@ -1946,18 +1780,14 @@ async def gitlab_retry_job( job_id: Annotated[int, Field(description="Job ID")], ) -> str: """Retry a failed job. Returns the new job's id, status, name, stage, and web_url.""" - try: - _check_write(ctx) - data = await _get_client(ctx).retry_job(project_id, job_id) - return _ok(_slim_job(data)) - except Exception as e: - return _err(e) + return _ok(_slim_job(await _get_client(ctx).retry_job(project_id, job_id))) @mcp.tool( tags={"gitlab", "jobs", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_play_job( ctx: Context, project_id: Annotated[ @@ -1973,18 +1803,14 @@ async def gitlab_play_job( Returns the started job's id, status, name, and web_url. """ - try: - _check_write(ctx) - data = await _get_client(ctx).play_job(project_id, job_id, variables) - return _ok(_slim_job(data)) - except Exception as e: - return _err(e) + return _ok(_slim_job(await _get_client(ctx).play_job(project_id, job_id, variables))) @mcp.tool( tags={"gitlab", "jobs", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_cancel_job( ctx: Context, project_id: Annotated[ @@ -1993,18 +1819,14 @@ async def gitlab_cancel_job( job_id: Annotated[int, Field(description="Job ID")], ) -> str: """Cancel a running job. Returns the updated job with status=canceled.""" - try: - _check_write(ctx) - data = await _get_client(ctx).cancel_job(project_id, job_id) - return _ok(_slim_job(data)) - except Exception as e: - return _err(e) + return _ok(_slim_job(await _get_client(ctx).cancel_job(project_id, job_id))) @mcp.tool( tags={"gitlab", "jobs", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_job_log( ctx: Context, project_id: Annotated[ @@ -2020,20 +1842,17 @@ async def gitlab_get_job_log( Returns {log, total_lines, shown_lines}. Pass tail_lines=0 for the whole log. """ - try: - log_text = await _get_client(ctx).get_job_log(project_id, job_id) - lines = log_text.splitlines() - if tail_lines and len(lines) > tail_lines: - lines = lines[-tail_lines:] - return _ok( - { - "log": "\n".join(lines), - "total_lines": len(log_text.splitlines()), - "shown_lines": len(lines), - } - ) - except Exception as e: - return _err(e) + log_text = await _get_client(ctx).get_job_log(project_id, job_id) + lines = log_text.splitlines() + if tail_lines and len(lines) > tail_lines: + lines = lines[-tail_lines:] + return _ok( + { + "log": "\n".join(lines), + "total_lines": len(log_text.splitlines()), + "shown_lines": len(lines), + } + ) # ════════════════════════════════════════════════════════════════════ @@ -2045,6 +1864,7 @@ async def gitlab_get_job_log( tags={"gitlab", "tags", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_tags( ctx: Context, project_id: Annotated[ @@ -2062,27 +1882,18 @@ async def gitlab_list_tags( Returns name, message, target sha, commit summary, and any attached release per tag. """ - try: - params: dict[str, Any] = {} - if search: - params["search"] = search - if order_by: - params["order_by"] = order_by - if sort: - params["sort"] = sort - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_tags(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_tags( + project_id, + _params(search=search, order_by=order_by, sort=sort, per_page=per_page, page=page), + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "tags", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_tag( ctx: Context, project_id: Annotated[ @@ -2091,17 +1902,14 @@ async def gitlab_get_tag( tag_name: Annotated[str, Field(description="Tag name", min_length=1)], ) -> str: """Get a tag's details. Returns name, message, target commit, and any attached release.""" - try: - data = await _get_client(ctx).get_tag(project_id, tag_name) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_tag(project_id, tag_name)) @mcp.tool( tags={"gitlab", "tags", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_tag( ctx: Context, project_id: Annotated[ @@ -2115,21 +1923,18 @@ async def gitlab_create_tag( Returns the new tag's name, message, target, and commit. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"tag_name": tag_name, "ref": ref} - if message: - params["message"] = message - data = await _get_client(ctx).create_tag(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_tag( + project_id, _params(tag_name=tag_name, ref=ref, message=message) + ) + ) @mcp.tool( tags={"gitlab", "tags", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_tag( ctx: Context, project_id: Annotated[ @@ -2138,12 +1943,8 @@ async def gitlab_delete_tag( tag_name: Annotated[str, Field(description="Tag name to delete", min_length=1)], ) -> str: """Delete a tag. Returns a {status: deleted, tag} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_tag(project_id, tag_name) - return _ok({"status": "deleted", "tag": tag_name}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_tag(project_id, tag_name) + return _ok({"status": "deleted", "tag": tag_name}) # ════════════════════════════════════════════════════════════════════ @@ -2155,6 +1956,7 @@ async def gitlab_delete_tag( tags={"gitlab", "releases", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_releases( ctx: Context, project_id: Annotated[ @@ -2169,21 +1971,17 @@ async def gitlab_list_releases( Returns tag_name, name, description, created_at, released_at, and assets per release. """ - try: - params: dict[str, Any] = {} - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_releases(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_releases( + project_id, _params(per_page=per_page, page=page) + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "releases", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_release( ctx: Context, project_id: Annotated[ @@ -2195,17 +1993,14 @@ async def gitlab_get_release( Returns name, description, tag_name, created_at, and assets (links, sources). """ - try: - data = await _get_client(ctx).get_release(project_id, tag_name) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_release(project_id, tag_name)) @mcp.tool( tags={"gitlab", "releases", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_release( ctx: Context, project_id: Annotated[ @@ -2225,29 +2020,22 @@ async def gitlab_create_release( Returns the new release with tag_name, name, description, and assets. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"tag_name": tag_name} - if name is not None: - params["name"] = name - if description is not None: - params["description"] = description - if ref is not None: - params["ref"] = ref - if released_at is not None: - params["released_at"] = released_at - if links is not None: - params["assets"] = {"links": links} - data = await _get_client(ctx).create_release(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + params = _params( + tag_name=tag_name, + name=name, + description=description, + ref=ref, + released_at=released_at, + assets={"links": links} if links is not None else None, + ) + return _ok(await _get_client(ctx).create_release(project_id, params)) @mcp.tool( tags={"gitlab", "releases", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_release( ctx: Context, project_id: Annotated[ @@ -2259,25 +2047,20 @@ async def gitlab_update_release( released_at: Annotated[str | None, Field(description="New release date (ISO 8601)")] = None, ) -> str: """Update a release's name, description, or release date. Returns the updated release.""" - try: - _check_write(ctx) - params: dict[str, Any] = {} - if name is not None: - params["name"] = name - if description is not None: - params["description"] = description - if released_at is not None: - params["released_at"] = released_at - data = await _get_client(ctx).update_release(project_id, tag_name, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_release( + project_id, + tag_name, + _params(name=name, description=description, released_at=released_at), + ) + ) @mcp.tool( tags={"gitlab", "releases", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_release( ctx: Context, project_id: Annotated[ @@ -2289,12 +2072,8 @@ async def gitlab_delete_release( Returns a {status: deleted, tag_name} confirmation. """ - try: - _check_write(ctx) - await _get_client(ctx).delete_release(project_id, tag_name) - return _ok({"status": "deleted", "tag_name": tag_name}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_release(project_id, tag_name) + return _ok({"status": "deleted", "tag_name": tag_name}) # ════════════════════════════════════════════════════════════════════ @@ -2306,6 +2085,7 @@ async def gitlab_delete_release( tags={"gitlab", "variables", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_variables( ctx: Context, project_id: Annotated[ @@ -2318,20 +2098,18 @@ async def gitlab_list_variables( Returns key, value (shown as '***MASKED***' when masked), protected, masked, environment_scope per variable. """ - try: - data, next_page = await _get_client(ctx).list_variables(project_id, page) - for var in data: - if var.get("masked"): - var["value"] = "***MASKED***" - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_variables(project_id, page) + for var in data: + if var.get("masked"): + var["value"] = "***MASKED***" + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_variable( ctx: Context, project_id: Annotated[ @@ -2355,31 +2133,28 @@ async def gitlab_create_variable( Returns the new variable's key, value (masked if applicable), protected, masked, environment_scope. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"key": key, "value": value} - if variable_type is not None: - params["variable_type"] = variable_type - if protected is not None: - params["protected"] = protected - if masked is not None: - params["masked"] = masked - if raw is not None: - params["raw"] = raw - if environment_scope is not None: - params["environment_scope"] = environment_scope - if description is not None: - params["description"] = description - data = await _get_client(ctx).create_variable(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_variable( + project_id, + _params( + key=key, + value=value, + variable_type=variable_type, + protected=protected, + masked=masked, + raw=raw, + environment_scope=environment_scope, + description=description, + ), + ) + ) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_variable( ctx: Context, project_id: Annotated[ @@ -2401,31 +2176,27 @@ async def gitlab_update_variable( environment_scope selects which scoped variable to update; it does not change the scope. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"value": value} - if variable_type is not None: - params["variable_type"] = variable_type - if protected is not None: - params["protected"] = protected - if masked is not None: - params["masked"] = masked - if raw is not None: - params["raw"] = raw - if description is not None: - params["description"] = description - data = await _get_client(ctx).update_variable( - project_id, key, params, environment_scope=environment_scope - ) - return _ok(data) - except Exception as e: - return _err(e) + data = await _get_client(ctx).update_variable( + project_id, + key, + _params( + value=value, + variable_type=variable_type, + protected=protected, + masked=masked, + raw=raw, + description=description, + ), + environment_scope=environment_scope, + ) + return _ok(data) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_variable( ctx: Context, project_id: Annotated[ @@ -2435,12 +2206,8 @@ async def gitlab_delete_variable( environment_scope: Annotated[str | None, Field(description="Environment scope filter")] = None, ) -> str: """Delete a project CI/CD variable. Returns a {status: deleted, key} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_variable(project_id, key, environment_scope) - return _ok({"status": "deleted", "key": key}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_variable(project_id, key, environment_scope) + return _ok({"status": "deleted", "key": key}) # ════════════════════════════════════════════════════════════════════ @@ -2452,6 +2219,7 @@ async def gitlab_delete_variable( tags={"gitlab", "variables", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_group_variables( ctx: Context, group_id: Annotated[str, Field(description="Group ID, path, or full GitLab URL", min_length=1)], @@ -2462,20 +2230,18 @@ async def gitlab_list_group_variables( Returns key, value (shown as '***MASKED***' when masked), protected, masked, environment_scope per variable. """ - try: - data, next_page = await _get_client(ctx).list_group_variables(group_id, page) - for var in data: - if var.get("masked"): - var["value"] = "***MASKED***" - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_group_variables(group_id, page) + for var in data: + if var.get("masked"): + var["value"] = "***MASKED***" + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_group_variable( ctx: Context, group_id: Annotated[str, Field(description="Group ID, path, or full GitLab URL", min_length=1)], @@ -2493,31 +2259,28 @@ async def gitlab_create_group_variable( Returns the new variable's key, value (masked if applicable), protected, masked, environment_scope. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"key": key, "value": value} - if variable_type is not None: - params["variable_type"] = variable_type - if protected is not None: - params["protected"] = protected - if masked is not None: - params["masked"] = masked - if raw is not None: - params["raw"] = raw - if environment_scope is not None: - params["environment_scope"] = environment_scope - if description is not None: - params["description"] = description - data = await _get_client(ctx).create_group_variable(group_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_group_variable( + group_id, + _params( + key=key, + value=value, + variable_type=variable_type, + protected=protected, + masked=masked, + raw=raw, + environment_scope=environment_scope, + description=description, + ), + ) + ) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_group_variable( ctx: Context, group_id: Annotated[str, Field(description="Group ID, path, or full GitLab URL", min_length=1)], @@ -2530,41 +2293,35 @@ async def gitlab_update_group_variable( description: Annotated[str | None, Field(description="Variable description")] = None, ) -> str: """Update a group CI/CD variable. Returns the updated variable object.""" - try: - _check_write(ctx) - params: dict[str, Any] = {"value": value} - if variable_type is not None: - params["variable_type"] = variable_type - if protected is not None: - params["protected"] = protected - if masked is not None: - params["masked"] = masked - if raw is not None: - params["raw"] = raw - if description is not None: - params["description"] = description - data = await _get_client(ctx).update_group_variable(group_id, key, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_group_variable( + group_id, + key, + _params( + value=value, + variable_type=variable_type, + protected=protected, + masked=masked, + raw=raw, + description=description, + ), + ) + ) @mcp.tool( tags={"gitlab", "variables", "write"}, annotations={"destructiveHint": True, "readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_delete_group_variable( ctx: Context, group_id: Annotated[str, Field(description="Group ID, path, or full GitLab URL", min_length=1)], key: Annotated[str, Field(description="Variable key", min_length=1)], ) -> str: """Delete a group CI/CD variable. Returns a {status: deleted, key} confirmation.""" - try: - _check_write(ctx) - await _get_client(ctx).delete_group_variable(group_id, key) - return _ok({"status": "deleted", "key": key}) - except Exception as e: - return _err(e) + await _get_client(ctx).delete_group_variable(group_id, key) + return _ok({"status": "deleted", "key": key}) # ════════════════════════════════════════════════════════════════════ @@ -2576,6 +2333,7 @@ async def gitlab_delete_group_variable( tags={"gitlab", "issues", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_list_issues( ctx: Context, project_id: Annotated[ @@ -2594,29 +2352,25 @@ async def gitlab_list_issues( Returns iid, title, state, labels, author, web_url per issue. """ - try: - params: dict[str, Any] = {} - if state: - params["state"] = state - if labels: - params["labels"] = labels - if search: - params["search"] = search - if assignee_id: - params["assignee_id"] = assignee_id - if per_page: - params["per_page"] = per_page - params["page"] = page - data, next_page = await _get_client(ctx).list_issues(project_id, params or None) - return _paginated(data, next_page) - except Exception as e: - return _err(e) + data, next_page = await _get_client(ctx).list_issues( + project_id, + _params( + state=state, + labels=labels, + search=search, + assignee_id=assignee_id, + per_page=per_page, + page=page, + ), + ) + return _paginated(data, next_page) @mcp.tool( tags={"gitlab", "issues", "read"}, annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result async def gitlab_get_issue( ctx: Context, project_id: Annotated[ @@ -2628,17 +2382,14 @@ async def gitlab_get_issue( Returns iid, title, description, state, labels, assignees, author, milestone, due_date, web_url. """ - try: - data = await _get_client(ctx).get_issue(project_id, issue_iid) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).get_issue(project_id, issue_iid)) @mcp.tool( tags={"gitlab", "issues", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_create_issue( ctx: Context, project_id: Annotated[ @@ -2656,31 +2407,27 @@ async def gitlab_create_issue( Returns the new issue's iid, title, state, labels, assignees, author, web_url. """ - try: - _check_write(ctx) - params: dict[str, Any] = {"title": title} - if description is not None: - params["description"] = description - if labels is not None: - params["labels"] = labels - if assignee_ids is not None: - params["assignee_ids"] = assignee_ids - if milestone_id is not None: - params["milestone_id"] = milestone_id - if confidential is not None: - params["confidential"] = confidential - if weight is not None: - params["weight"] = weight - data = await _get_client(ctx).create_issue(project_id, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).create_issue( + project_id, + _params( + title=title, + description=description, + labels=labels, + assignee_ids=assignee_ids, + milestone_id=milestone_id, + confidential=confidential, + weight=weight, + ), + ) + ) @mcp.tool( tags={"gitlab", "issues", "write"}, annotations={"readOnlyHint": False, "idempotentHint": True, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_update_issue( ctx: Context, project_id: Annotated[ @@ -2698,31 +2445,27 @@ async def gitlab_update_issue( Returns the updated issue object. """ - try: - _check_write(ctx) - params: dict[str, Any] = {} - if title is not None: - params["title"] = title - if description is not None: - params["description"] = description - if labels is not None: - params["labels"] = labels - if assignee_ids is not None: - params["assignee_ids"] = assignee_ids - if state_event is not None: - params["state_event"] = state_event - if weight is not None: - params["weight"] = weight - data = await _get_client(ctx).update_issue(project_id, issue_iid, params) - return _ok(data) - except Exception as e: - return _err(e) + return _ok( + await _get_client(ctx).update_issue( + project_id, + issue_iid, + _params( + title=title, + description=description, + labels=labels, + assignee_ids=assignee_ids, + state_event=state_event, + weight=weight, + ), + ) + ) @mcp.tool( tags={"gitlab", "issues", "write"}, annotations={"readOnlyHint": False, "openWorldHint": True}, ) +@tool_result(write=True) async def gitlab_add_issue_comment( ctx: Context, project_id: Annotated[ @@ -2732,9 +2475,4 @@ async def gitlab_add_issue_comment( body: Annotated[str, Field(description="Comment body (markdown)", min_length=1)], ) -> str: """Post a comment on an issue. Returns the new note's id, body, author, and created_at.""" - try: - _check_write(ctx) - data = await _get_client(ctx).add_issue_comment(project_id, issue_iid, body) - return _ok(data) - except Exception as e: - return _err(e) + return _ok(await _get_client(ctx).add_issue_comment(project_id, issue_iid, body)) diff --git a/tests/unit/test_tool_contract.py b/tests/unit/test_tool_contract.py index 1029d33..9545ac5 100644 --- a/tests/unit/test_tool_contract.py +++ b/tests/unit/test_tool_contract.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +import logging import pytest from httpx import Response @@ -692,3 +693,33 @@ async def test_forced_failure(tool_client, row): async def test_every_tool_has_a_row(tool_client): client, _router = tool_client assert {r[0] for r in ROWS} == {t.name for t in await client.list_tools()} + + +async def test_bug_is_a_tool_error_not_a_result(tool_client, caplog): + """Anything that is not a GitLabError is a bug: isError with the type name, logged with + its traceback. Before the tool_result decorator this came back as a successful result.""" + client, router = tool_client + router.get("/projects/123").mock(side_effect=RuntimeError("boom")) + + with caplog.at_level(logging.ERROR, logger="mcp_gitlab.servers.gitlab"): + result = await client.call_tool( + "gitlab_get_project", {"project_id": "123"}, raise_on_error=False + ) + + assert result.is_error is True + assert "RuntimeError: boom" in result.content[0].text + logged = [r for r in caplog.records if r.name == "mcp_gitlab.servers.gitlab"] + assert logged and logged[0].exc_info is not None + + +async def test_api_error_is_still_a_result(tool_client): + """Expected failures keep the JSON envelope; the MCP-level error flag stays off.""" + client, router = tool_client + router.get("/projects/123").mock(return_value=Response(404, json={"message": "gone"})) + + result = await client.call_tool( + "gitlab_get_project", {"project_id": "123"}, raise_on_error=False + ) + + assert result.is_error is False + assert "hint" in json.loads(result.content[0].text) diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index ff9da35..f86db20 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -6,6 +6,7 @@ from typing import Any import pytest +from fastmcp.exceptions import ToolError from httpx import Response @@ -2388,16 +2389,21 @@ async def test_get_job_log_rejects_negative_tail(self, tool_client): {"project_id": "123", "job_id": 1, "tail_lines": -5}, ) - async def test_share_group_with_group_invalid_level_lists_valid_levels(self, tool_client): - """Error message must list the valid levels, matching its project-share sibling.""" - client, router = tool_client - result = await client.call_tool( - "gitlab_share_group_with_group", - {"target_group_id": "9", "source_group_id": 1, "access_level": "bogus"}, - ) - parsed = _parse(result) - assert "bogus" in parsed["error"] - assert "maintainer" in parsed["error"] + @pytest.mark.parametrize( + "name", ["gitlab_share_group_with_group", "gitlab_share_project_with_group"] + ) + async def test_invalid_access_level_is_rejected_by_the_schema(self, tool_client, name): + """A bad level is an input error, not a successful result carrying an error key.""" + client, router = tool_client + args = {"access_level": "bogus"} + if name == "gitlab_share_group_with_group": + args |= {"target_group_id": "9", "source_group_id": 1} + else: + args |= {"project_id": "9", "group_id": 1} + with pytest.raises(ToolError) as exc_info: + await client.call_tool(name, args) + assert "maintainer" in str(exc_info.value) + assert not router.calls async def test_tool_descriptions_have_no_blank_line_runs(self, tool_client): """Docstrings ship verbatim as MCP tool descriptions — no stray blank-line runs."""