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
-
The work item resolves server-side to a global ID via a permission-aware finder, then
Query.workItem(id:)is used, since neitherProject.workItem(iid:)(absent from the schema) norNamespace.workItem(iid:)(null without theepicslicense) 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. -
EE widgets are added through a whole-document substitution: an EE twin of the
.graphqlfile underee/app/graphql/queries/mcp/is picked up by the EEload_graphqloverride, statically per edition. Both documents are schema-validated byall_queries_specand linted by graphql-verify; an EE spec guards the CE-base selection against drift. -
The replaced tool's pagination parameters are kept as deprecated aliases:
mr_page_sizeandmr_pagination_cursormap ontorelated_merge_requests_firstandrelated_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. Notool_aliasesentry is added: the Python tool was never served over MCP, so no client has the old name cached. -
related_merge_requestsis 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. -
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, sinceget_workitem_notesremains the paginated path for notes. -
In
app/graphql/types/work_items/widgets/hierarchy_type.rb,has_childrenandhas_parentnow declarecomplexity: 1, matching the neighboringparent,children, andancestorsfields, 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_childrenis aBatchLoaderlazy 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 insidehas_children's resolution window and trigger a falseWorkItemWidgetHierarchy.hasChildren unexpectedly calls Gitaly!error. Declaring an integer complexity is the tracker's own sanctioned exemption (its gating condition isconstant complexity || calls_gitaly), and it causes no production behavior change since fields already default to complexity 1. A regression spec drives the tool withinclude: notesover a commit-mention system note on a cold markdown cache (:request_store) and fails without this declaration.
References
- Issue: #605882 (closed)
- Bucket epic: gitlab-org#22782 (closed)
- DAP migration plan: #609451
- Precedent MR: !248373 (merged)
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 leakInteractive 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.

Base fetch result: compact object; widgets with no facets requested stay bare.

EE document with STATUS widget: system-defined status present, structuredContent populated.

How to set up and validate locally
- Restart Rails web so the registry picks up the new entry:
gdk restart rails-web. - 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! - Call
tools/listover the MCP endpoint with curl; select the entry namedget_work_item. - Call with
project_idandwork_item_iid, then with the URL; confirm both return the same compact object. - Call
include: ["notes"], theninclude: ["related_merge_requests"]withrelated_merge_requests_first: 2; confirmmr_page_size: 2behaves identically. - Send
includewith both facets and confirm themaxItemserror; 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.