Add get_work_item MCP server tool

What does this MR do and why?

This MR adds get_work_item, a GraphQL-backed MCP server tool implementing #605882 (closed) from the work-items tools bucket gitlab-org#22782 (closed).

The tool is a single-object reader for one work item (issue, epic, task, incident, objective, or key result), returning type, dates, assignees, labels, milestone, and parent. Two facets ride an include array (enum, maxItems: 1): notes (first 100) and related_merge_requests (cursor-paginated). Identification is by URL, or work_item_iid with group_id/project_id. The tool is read-only; the EE document adds HealthStatus and Status widgets.

The tool replaces the DAP Python get_work_item tool and folds the get_work_item_notes read in as a facet. The existing get_workitem_notes tool stays available during migration, per the issue.

The change follows the facet pattern from !248373 (merged) (get_merge_request) and the gitlab-mcp-tool-builder skill.

Design decisions
  1. The work item resolves server-side to a global ID via a permission-aware finder, then Query.workItem(id:) is used, since neither Project.workItem(iid:) (absent from the schema) nor Namespace.workItem(iid:) (null without the epics license) fits as the root. This also yields one uniform not-found error: a confidential item and a nonexistent iid produce the same message, verified end to end.

  2. EE widgets are added through a whole-document substitution: an EE twin of the .graphql file under ee/app/graphql/queries/mcp/ is picked up by the EE load_graphql override, statically per edition. Both documents are schema-validated by all_queries_spec and linted by graphql-verify; an EE spec guards the CE-base selection against drift.

  3. The replaced tool's pagination parameters are kept as deprecated aliases: mr_page_size and mr_pagination_cursor map onto related_merge_requests_first and related_merge_requests_after, and the canonical parameter wins when both are given. Both are marked DEPRECATED and transitional, to be dropped once usage signal justifies it. No tool_aliases entry is added: the Python tool was never served over MCP, so no client has the old name cached.

  4. related_merge_requests is empty for group-level work items, since the resolver returns none for them, documented in the schema description rather than an error. The field's one-call-per-request limit fits a single-object fetch.

  5. Cut from this version, additive later: the Python tool's types[] argument (existed only for tier checks the monolith already enforces via licensing); the LinkedItems widget; hierarchy children/ancestors pagination; and notes pagination parameters, since get_workitem_notes remains the paginated path for notes.

  6. In app/graphql/types/work_items/widgets/hierarchy_type.rb, has_children and has_parent now declare complexity: 1, matching the neighboring parent, children, and ancestors fields, which already use a constant complexity. This works around GitLab's dev/test-only Gitaly-call tracker (Gitlab::Graphql::CallsGitaly::FieldExtension), which force-resolves lazy fields and attributes any Gitaly call count observed during that resolution to the field being checked; has_children is a BatchLoader lazy field, and when it is co-selected with this tool's notes facet, a cold-markdown-cache redaction render of a "mentioned in commit ..." system note (itself a legitimate, calls_gitaly-marked operation) can land inside has_children's resolution window and trigger a false WorkItemWidgetHierarchy.hasChildren unexpectedly calls Gitaly! error. Declaring an integer complexity is the tracker's own sanctioned exemption (its gating condition is constant complexity || calls_gitaly), and it causes no production behavior change since fields already default to complexity 1. A regression spec drives the tool with include: notes over a commit-mention system note on a cold markdown cache (:request_store) and fails without this declaration.

References

The bucket issue carries the ratified per-tool spec.

Screenshots or screen recordings

No UI changes.

All identification paths and facets were exercised end to end on a running GDK (EE, SaaS-simulated), via raw JSON-RPC and the MCP Inspector CLI, using the official SDK handshake.

== tools/list: advertised=true annotations={"readOnlyHint"=>true} schema_size=1606 chars
== base fetch (project_id+iid): isError=false iid=2 type=Issue widgets_have_notes=false
== base fetch (url): isError=false
== include notes: isError=false note_count=18 pageInfo=["hasNextPage", "endCursor"]
== include related_merge_requests: isError=false nodes+pageInfo present
== deprecated alias mr_page_size: same shape as canonical param
== group-level work item (epic): DEVELOPMENT widget omitted (type does not support it)
== EE document active: HEALTH_STATUS widget present (healthStatus=onTrack via Inspector CLI)
== include maxItems=2 rejected: Validation error: include cannot contain more than 1 items
== unknown param rejected: Validation error: bogus is invalid
== nonexistent iid:            Validation error: Work item #99999999 not found
== confidential as guest:      Validation error: Work item #27 not found   <- same message, no leak

Interactive walkthrough conducted in the MCP Inspector web UI on the same GDK EE instance, connected via mcp-remote using OAuth through the real nginx entry point. The session covers the tools/list schema view, a base fetch by project_id and work_item_iid, the notes facet (18 notes, including commit cross-reference system notes), the related_merge_requests facet with related_merge_requests_first=2, the deprecated alias parameter mr_page_size=2 (confirmed to return the identical response shape as the non-deprecated equivalent), rejection of an include argument exceeding maxItems, and rejection of a nonexistent work item iid.

MCP Inspector walkthrough (schema, base fetch, EE status widget)

Advertised schema: read-only badge, include enum rendered as a single-select (maxItems 1), deprecated alias parameters visible. get_work_item schema in MCP Inspector

Base fetch result: compact object; widgets with no facets requested stay bare. get_work_item base fetch result in MCP Inspector

EE document with STATUS widget: system-defined status present, structuredContent populated. get_work_item EE status widget in MCP Inspector

How to set up and validate locally

  1. Restart Rails web so the registry picks up the new entry: gdk restart rails-web.
  2. Create a personal access token scoped to [:api, :mcp] in the Rails console:
    token = User.find_by_username('root').personal_access_tokens.create!(
      name: 'mcp-verify', scopes: [:api, :mcp], expires_at: 7.days.from_now)
    token.set_token('someknownvalue')
    token.save!
  3. Call tools/list over the MCP endpoint with curl; select the entry named get_work_item.
  4. Call with project_id and work_item_iid, then with the URL; confirm both return the same compact object.
  5. Call include: ["notes"], then include: ["related_merge_requests"] with related_merge_requests_first: 2; confirm mr_page_size: 2 behaves identically.
  6. Send include with both facets and confirm the maxItems error; then call a nonexistent iid, and separately a confidential item as a non-member, and confirm the same not-found message.

Automated coverage: tool and service unit specs, incl. a full input_schema version lock, an alias-precedence matrix, and uniform not-found coverage; an EE spec locks the EE document; CE/EE list_tools annotation contracts are covered; all_queries_spec validates both .graphql documents.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Tian Gao

Merge request reports

Loading
Loading