Add save_work_item MCP server tool
What does this MR do and why?
This MR adds save_work_item, a GraphQL-backed MCP server tool that creates or updates a work item through a single field-mutation call. Dispatch is based on the presence of work_item_iid: absent means create (requiring title and type_name), present means update. The tool folds the two existing DAP Python tools, create_work_item and update_work_item, into one MCP-native tool; both old names are registered as tool_aliases that resolve to save_work_item on tools/call, while tools/list advertises only the canonical name. The merged input schema is a backward-compatible superset of both Python tools' schemas.
This work migrates work item creation/update off the DAP Python tool layer and onto the native MCP server, following the same service-dispatch pattern already used for read tools. Deprecating the Python create_work_item/update_work_item tools is tracked separately so DAP callers have a migration window before the aliases are removed.
The implementation is one MCP-facing service, SaveWorkItemService (the tool registered with the server), which dispatches to two internal tool classes: CreateWorkItemTool wrapping the workItemCreate mutation and UpdateWorkItemTool wrapping workItemUpdate. Each has its own committed .graphql document and its own operation_name, keeping each mutation independently versioned. Both tools normalize their response into the same compact reference shape (id, iid, type, title, state, confidential, web_url), and shared parameter/GID handling lives in a SaveWorkItemCommon module. This mirrors the existing dispatch precedent in get_saved_view_work_items, and follows the MCP tool framework's guidance that a tool should wrap at most one mutation per document, pushing multi-operation behavior to service-level composition instead.
Closes #605852 (closed).
Design decisions
- Service-level dispatch (one service, two tool classes, two
.graphqldocuments) instead of a single tool switchingoperation_nameat call time. This keeps each mutation declaratively versioned with its own document, and avoids output-shape drift between create and update responses. type_nameis a free string, not an enum, because valid work item types depend on namespace and license (for example, Epic, Objective, and Key Result do not exist on CE). It is resolved case-insensitively server-side viaWorkItems::TypesFramework::Provider; an invalid value returns the namespace's list of valid types. This also preserves the old DAP call shape, which passed lowercase values such astype_name="issue".- Create-required fields (
title,type_name) are enforced in Ruby, with "Required on create." called out in the parameter descriptions, rather than expressed as JSON-schema conditional requirements. Conditional requirements would need root-levelallOf/if/thencomposition, which disables the framework's unknown-argument rejection. - Widget-vs-type and licensing validation is delegated to the underlying mutations' own
extract_widget_params!, which already returns a precise error (for example, "Following widget keys are not supported by X type: [...]"). The tool does not maintain its own copy of the widget support matrix. clear_weightmaps to an explicitweightWidget: { weight: null }mutation input, because that is the only GraphQL-level way to clear weight. The framework treatsnull/""params as omitted, so a boolean flag is required to distinguish "clear" from "don't touch."- EE-licensed params (
weight,clear_weight,health_status,status_id,is_fixed,agent_plan) are added through an EEinput_schemadeep-merge. On CE, passing any of them is rejected as an unknown argument by the framework. - The issue spec's nested
hierarchy_widget: { parent_id }object is flattened to a top-levelparent_idparam, removing one level of nesting for agent callers with no loss of expressiveness. - Update-only params (
state,add_label_ids,remove_label_ids,todo_action,todo_id,clear_weight) and create-only params (type_name,label_ids) are cross-rejected with explicit validation errors when used on the wrong operation. - Quick-action lines (starting with
/) are rejected indescription, matching the rule the DAP Python tools already documented. - Numeric IDs passed for assignees, labels, parent, and todo are normalized to GraphQL global IDs, so agents can pass plain integers instead of constructing GIDs themselves.
References
- Issue: #605852 (closed)
- Work items bucket: gitlab-org#22782 (closed)
- DAP Python tool deprecation tracking: #609451
- Sibling read tools (same service-dispatch pattern): !250080 (merged), !250081 (merged)
- Write precedent for this tool family: the existing
create_workitem_noteMCP tool
Screenshots or screen recordings
No UI changes.
Exercised end to end on a running GDK (EE, SaaS-simulated) via raw JSON-RPC using the official SDK handshake:
== tools/list: advertised=true annotations={"readOnlyHint"=>false, "destructiveHint"=>false} aliases_hidden=true
== EE schema overlay active: ["agent_plan", "clear_weight", "health_status", "is_fixed", "status_id", "weight"]
== create (type_name lowercase): isError=false keys=["confidential", "id", "iid", "state", "title", "type", "web_url"] type=Issue state=OPEN
== update via alias update_work_item: isError=false title=e2e save updated
== close: isError=false state=CLOSED
== reopen: isError=false state=OPEN
== weight=5: isError=false db_weight=5
== clear_weight: isError=false db_weight=nil
== todo add: isError=false
== create via alias at group scope (Epic): isError=false type=Epic
== create missing title: isError=true msg=Validation error: title is required when creating a work item
== bogus type_name: isError=true msg=Validation error: Work item type 'bogus_type' not found. Valid types: Epic, Incident, Issue, Requirement, Task
== update-only param create: isError=true msg=Validation error: state can only be used when updating (provide work_item_iid)
== quick action rejected: isError=true msg=Validation error: Quick actions (commands starting with /) are not allowed in description
== nonexistent iid: isError=true msg=Validation error: Work item #99999999 not found
== unknown param: isError=true msg=Validation error: bogus is invalid
== weight on Incident: isError=true msg=Following widget keys are not supported by Incident type: [:weight_widget]MCP Inspector walkthrough (create, update, EE fields, clear_weight)
Interactive walkthrough via MCP Inspector connected over mcp-remote OAuth through the GDK EE nginx entry point:
tools/listadvertisessave_work_itemas a write tool (no read-only badge). Thecreate_work_item/update_work_itemaliases are not listed — they only resolve ontools/call. The advertised schema includes the EE overlay fieldshealth_status,weight,clear_weight,status_id,is_fixed,agent_plan.- Create:
project_id+title+type_name: Issuereturns the compact result{id, iid, type, title, state: OPEN, confidential, web_url}(created iid 30). - Update by
work_item_iid: a title change plusconfidential: trueis reflected in the compact result. - EE fields update:
weight: 5+health_status: onTrack, verified both in the web UI sidebar (Weight 5, Health status On track) and in the database (weight=5,health_status="on_track"). clear_weightsemantics: sendingweight: 3andclear_weight: truetogether clears the weight (databaseweight=nil) — clear wins over weight, and the explicit-nil path survives argument compaction.- The Inspector renders
health_statusas a select, so invalid enum values can't even be typed. - Rejections, exact server text:
Validation error: Provide exactly one of url, project_id, or group_id (got project_id, group_id)Validation error: title is required when creating a work item
The browser screenshot also shows the full activity audit trail of the walkthrough (status set, title change, confidential, weight, health status) on the created item.
Create call form (advertised schema)

Create result (compact payload, iid 30)

EE overlay fields in the form (health_status select, weight, clear_weight)

Web UI after the EE update — sidebar Weight 5 / Health status On track / Confidential badge, plus activity audit trail

Automated coverage: tool and service unit specs, including a full input_schema version lock, create/update routing, the update-only/create-only cross-rejection matrices, GID normalization, alias resolution through the tool Manager, uniform not-found handling, EE-licensed integration coverage (setting and clearing weight), CE/EE list_tools annotation contracts, and an all_queries_spec that validates both .graphql documents.
How to set up and validate locally
- Restart Rails to pick up the new tool:
gdk restart rails-web. - Create a personal access token with the
apiandmcpscopes. - Call
tools/listover the MCP endpoint and confirmsave_work_itemis advertised;create_work_itemandupdate_work_itemshould not appear in the list (aliases only resolve ontools/call). - Call
save_work_itemwithproject_id,title, andtype_nameset, with nowork_item_iid, and confirm a work item is created. - Call
save_work_itemagain with the returnedwork_item_iidto updatetitleand setstateto closed, then repeat the create/update flow using the legacy tool namecreate_work_itemand confirm identical behavior through the alias. - Call
save_work_itemwithweightset on an Incident-type work item and confirm it returns the widget-support validation error instead of silently ignoring the param.
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.
