Skip to content

feat: report tool bugs as MCP errors and collapse the 83x tool template - #88

Merged
vish288 merged 1 commit into
mainfrom
contract-gitlab
Sep 26, 2026
Merged

vish288 merged 1 commit into
mainfrom
contract-gitlab

Conversation

@vish288

@vish288 vish288 commented Sep 26, 2026

Copy link
Copy Markdown
Owner

What

One decorator, tool_result, applied under @mcp.tool, replaces the try / _check_write / if-not-None / except Exception template in all 83 tools. Bodies are now the call plus _ok / _paginated.

  • GitLabError subclasses (401/403/404, 409/422/429, read-only) keep today's JSON envelope byte-for-byte; the 166 contract tests from test: per-tool wire contract, real-lifespan assembly test and a 98% coverage gate #87 assert the same wire requests and _err key sets.
  • Anything else is a bug: logged with its traceback and re-raised as ToolError, so the client sees isError: true with the exception type in the message. Two new tests drive both paths through Client(mcp) (respx RuntimeError side effect; mocked 404).
  • write=True folds in _check_write; its failure is expected and takes the envelope.
  • _params(**kw) drops None, so the 90 unrolled if x is not None guards become one keyword call per tool.
  • gitlab_merge_mr_sequence keeps its own GitLabError handler because its envelope carries merged_so_far (pinned by an existing read-only test); bugs still fall through to the decorator.
  • Access-level validation is hand-rolled twice and returned on the success channel #79 rides along: access_level is Literal["guest", "reporter", "developer", "maintainer", "owner"]; both hand-rolled checks that returned on the success channel are deleted.

functools.wraps preserves the signature fastmcp introspects: the assembly test still counts 83 tools and every contract row passes unchanged.

Numbers

before after
tests 405 408
servers/gitlab.py statements 1039 510
servers/gitlab.py coverage 99% 100%
src/ coverage 99% 99% (1008 stmts, 10 missed)
src/ lines −262

Gates: uv run pytest --cov --cov-fail-under=98, ruff check, ruff format --check, uv build all green. Converted one tool first and ran the full suite before the rest; failures never rose.

Behaviour notes for the release

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.

Minor: the few truthiness guards (if search: etc.) now forward an explicit empty value rather than silently dropping it. access_level is case-sensitive through the schema ("Developer" was previously lower-cased).

Fixes #75. Fixes #76. Fixes #79.

…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.

@vish288 vish288 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: would approve — self-approval blocked by GitHub

Schema diff, all 83 tools, before (75ec5c0) vs HEAD: 81 byte-identical. The 2 that changed are gitlab_share_project_with_group and gitlab_share_group_with_group, and the change is exactly #79 — access_level went from a bare string to enum: [guest, reporter, developer, maintainer, owner]. The decorator itself leaked nothing into any schema.

Two behaviour changes, both accepted, both belong in the minor bump:

  • Truthiness guards → _params. The old if search: silently dropped "", 0 and False — a client literally could not set a boolean flag to false. _params drops only None. That is a latent bug fixed, not a regression.
  • access_level is case-sensitive through the Literal. The old code lower-cased it. Since the schema now advertises the exact values, a schema-reading client sends them correctly; a hand-written "Developer" now fails at validation instead of silently working. Correct trade for MCP, and the reason this is 0.11.0 not 0.10.4.

Correctness. _check_write inside the try, so read-only keeps its envelope. merge_mr_sequence keeps a local except GitLabError because its envelope carries merged_so_far — documented, pinned by the existing read-only test, and bugs still fall through to the decorator. servers/gitlab.py 1039 → 510 statements, 100% covered.

Tests. RuntimeError via respx side_effect → is_error True, class name in content, exc_info record on the logger; mocked 404 → is_error False + hint; the bogus-level test now parametrized over both share tools and asserts no HTTP call was made.

Fixes #75. Fixes #76. Fixes #79. 408 passed, 99% src, gate 98 clears, ruff/format/build clean, CI green 3.10–3.13.

@vish288
vish288 merged commit 6d0c04a into main Sep 26, 2026
10 checks passed
@vish288
vish288 deleted the contract-gitlab branch September 26, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant