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
  1. Service-level dispatch (one service, two tool classes, two .graphql documents) instead of a single tool switching operation_name at call time. This keeps each mutation declaratively versioned with its own document, and avoids output-shape drift between create and update responses.
  2. type_name is 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 via WorkItems::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 as type_name="issue".
  3. 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-level allOf/if/then composition, which disables the framework's unknown-argument rejection.
  4. 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.
  5. clear_weight maps to an explicit weightWidget: { weight: null } mutation input, because that is the only GraphQL-level way to clear weight. The framework treats null/"" params as omitted, so a boolean flag is required to distinguish "clear" from "don't touch."
  6. EE-licensed params (weight, clear_weight, health_status, status_id, is_fixed, agent_plan) are added through an EE input_schema deep-merge. On CE, passing any of them is rejected as an unknown argument by the framework.
  7. The issue spec's nested hierarchy_widget: { parent_id } object is flattened to a top-level parent_id param, removing one level of nesting for agent callers with no loss of expressiveness.
  8. 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.
  9. Quick-action lines (starting with /) are rejected in description, matching the rule the DAP Python tools already documented.
  10. 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

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/list advertises save_work_item as a write tool (no read-only badge). The create_work_item / update_work_item aliases are not listed — they only resolve on tools/call. The advertised schema includes the EE overlay fields health_status, weight, clear_weight, status_id, is_fixed, agent_plan.
  • Create: project_id + title + type_name: Issue returns the compact result {id, iid, type, title, state: OPEN, confidential, web_url} (created iid 30).
  • Update by work_item_iid: a title change plus confidential: true is 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_weight semantics: sending weight: 3 and clear_weight: true together clears the weight (database weight=nil) — clear wins over weight, and the explicit-nil path survives argument compaction.
  • The Inspector renders health_status as 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 call form

Create result (compact payload, iid 30) Create result

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

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

clear_weight sent together with weight: 3 clear_weight semantics

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

  1. Restart Rails to pick up the new tool: gdk restart rails-web.
  2. Create a personal access token with the api and mcp scopes.
  3. Call tools/list over the MCP endpoint and confirm save_work_item is advertised; create_work_item and update_work_item should not appear in the list (aliases only resolve on tools/call).
  4. Call save_work_item with project_id, title, and type_name set, with no work_item_iid, and confirm a work item is created.
  5. Call save_work_item again with the returned work_item_iid to update title and set state to closed, then repeat the create/update flow using the legacy tool name create_work_item and confirm identical behavior through the alias.
  6. Call save_work_item with weight set 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.

Edited by Tian Gao

Merge request reports

Loading
Loading