How gitlab-mcp-server formats tool responses for both human and machine consumption.
Diátaxis type: Explanation Audience: 👤 End users, AI assistant users
Successful tool responses contain two representations of the same data:
- Markdown content — human-readable text with tables, clickable links, and next-step hints. Targeted at the LLM (
audience: assistant) so it can reason over the data and present it to you. - Structured JSON (
structuredContent) — machine-readable data for programmatic clients. IDEs like VS Code read this to extract fields, and it also includes anext_stepsarray with actionable hints.
flowchart LR
response[Tool response]
response --> content[Markdown content]
response --> structured[structuredContent JSON]
content --> markdownExample["## Branches (5)<br/>Markdown table with links<br/>Next-step hints"]
content --> annotation["Content annotation<br/>audience: assistant"]
annotation --> llm[Read by the LLM]
structured --> jsonExample["branches<br/>pagination<br/>next_steps"]
jsonExample --> ide[Read by IDEs and programmatic clients]
markdownExample -.->|same hints| jsonExample
When you ask your AI assistant a question like "Show me the open merge requests", the response typically includes:
List results include clickable links that open directly in GitLab:
| MR | Title | Author | Status |
|----|-------|--------|--------|
| [!243](https://gitlab.example.com/project/-/merge_requests/243) | Fix login | alice | open |
| [!241](https://gitlab.example.com/project/-/merge_requests/241) | Add tests | bob | open |Click on !243 to open the merge request in your browser. This works for merge requests, issues, pipelines, projects, branches, commits, releases, todos, milestones, members, and other list surfaces that include GitLab web URLs.
After each response, you will see suggested next actions:
💡 Next steps:
- Get details of a specific MR by its number
- Create a new merge request
- Approve or merge an open MR
These hints are available in both the Markdown and JSON output, so your IDE can display them regardless of which format it reads. Tool execution errors use isError: true and may omit structuredContent so clients do not confuse an error payload with a successful typed result.
- Dates appear in readable format (
15 Jan 2025 10:30 UTC, or15 Jan 2025for a value GitLab sends without a time) instead of raw ISO timestamps - Status values use emoji indicators (✅ success, ❌ failed, ⏳ running)
- Pagination shows "Page 1 of 3 (20 per page)" with hints to request more
Different MCP clients read different parts of the response:
| Client | Reads Markdown content |
Reads structuredContent JSON |
How hints arrive |
|---|---|---|---|
| VS Code / Copilot | ❌ Ignores | ✅ Primary | next_steps array in JSON |
| Cursor | ✅ Primary | ✅ Also available | Both 💡 Next steps in Markdown and next_steps in JSON |
| Claude Desktop | ✅ Primary | ✅ Also available | Both formats |
| CLI tools | ✅ Primary | ❌ Often ignored | 💡 Next steps in Markdown |
| Custom HTTP clients | Depends | Depends | Both available in JSON-RPC response |
The server ensures hints appear in both formats so no client misses them.
Every Markdown response includes MCP annotations that tell the client who the content is for and how important it is:
| Annotation | Audience | Priority | Used For |
|---|---|---|---|
ContentList |
assistant |
0.4 | List and search results |
ContentDetail |
assistant |
0.6 | Single-entity details (get, show) |
ContentMutate |
assistant |
0.8 | Create, update, delete confirmations |
ContentAssistant |
assistant |
0.7 | General assistant-targeted content |
ContentUser |
user |
0.8 | The image block of an upload or a visualization, shown to the user |
Every dispatcher finishes a result the same way: the text block carries the annotation the action's declared content kind resolves to (list, detail, mutate or the assistant default), an image block carries ContentUser, and the next-step hints are set on the typed output when its type declares next_steps.
When content is marked audience: ["assistant"], it tells the MCP client: "This content is for the AI to reason over, not for direct display to the user." This prevents the raw Markdown from being shown alongside the formatted JSON in clients like VS Code that render both. The LLM still sees and uses the Markdown — it just is not duplicated in the UI.
The priority value (0.0 to 1.0) hints to the client how important the content is relative to other content in the same response. Higher priority content should be processed first. For example, a mutation result (0.8) is more immediately relevant than a list result (0.4).
Separate from content annotations, every tool has behavioral annotations that describe what it does:
| Annotation | Type | Meaning |
|---|---|---|
readOnlyHint |
bool |
The tool only reads data, never modifies anything |
destructiveHint |
*bool |
The tool may perform irreversible operations (delete, drop) |
idempotentHint |
bool |
Calling the tool multiple times with the same input produces the same result |
openWorldHint |
*bool |
The tool interacts with external systems (GitLab API) |
These annotations help your AI assistant and IDE make safety decisions:
- Tools with
destructiveHint: truemay trigger confirmation prompts - Tools with
readOnlyHint: truecan be called freely without risk - Tools with
idempotentHint: trueare safe to retry on failure
When you ask "Show me the branches in my project":
Markdown content (what the LLM sees):
## Branches (5)
| Branch | Protected | Default | Merged | Web URL |
|--------|-----------|---------|--------|---------|
| [main](https://gitlab.example.com/.../main) | ✅ | ✅ | — | [↗](url) |
| [develop](https://gitlab.example.com/.../develop) | ✅ | — | — | [↗](url) |
| feature/login | — | — | — | [↗](url) |
Page 1 of 1 (20 per page) · 5 items
---
💡 **Next steps:**
- When presenting these results, always include the clickable [text](url) links
- Get details of a specific branch
- Create a new branch from any refStructured JSON (what VS Code reads):
{
"branches": [
{ "name": "main", "protected": true, "default": true, "web_url": "https://..." },
{ "name": "develop", "protected": true, "default": false, "web_url": "https://..." }
],
"pagination": { "page": 1, "per_page": 20, "total_items": 5, "total_pages": 1, "has_more": false },
"next_steps": [
"When presenting these results, always include the clickable [text](url) links",
"Get details of a specific branch",
"Create a new branch from any ref"
]
}When you ask "Show me merge request !243":
Markdown content:
## Merge Request !243: Fix Login Bug
- **Status**: open
- **Author**: [@alice](https://gitlab.example.com/alice)
- **Created**: 15 Mar 2025 10:30 UTC
- **Updated**: 20 Mar 2025 14:15 UTC
- **Source**: feature/fix-login → main
- **URL**: [!243](https://gitlab.example.com/project/-/merge_requests/243)
---
💡 **Next steps:**
- View the changes (diff) for this MR
- List discussions and review comments
- Approve or merge this MRA result about one object is a card: a heading and a list of
- **Label**: value rows. A table is for a collection of objects that share
columns, which is what a list response returns. The shape is not per formatter:
every card is written by one writer, and a hand-written row fails
make check-md-escaping. See
the card contract.
When you ask "Create a new issue titled 'Fix the login page'":
## Issue Created: #42 — Fix the login page
- **IID**: [#42](https://gitlab.example.com/project/-/issues/42)
- **State**: opened
- **Author**: you
- **Created**: 21 Mar 2025 09:00 UTC
---
💡 **Next steps:**
- Add labels or assignees to this issue
- Create a merge request linked to this issue
- Add a comment with more detailsWhen a "get" operation targets a resource that does not exist the server returns an informational result instead of an opaque error:
## ❓ Branch Not Found
The branch **"nonexistent" in project 42** does not exist or is not accessible with your current permissions.
---
💡 **Next steps:**
- Use action 'branch.list' to list the project's branches
- Verify the branch name is spelled correctly (case-sensitive)
Not-found responses have IsError: true but include actionable hints so the AI assistant can self-correct or suggest alternatives. This pattern covers 20 "get" handlers across 20 domains (toolutil.NotFoundResult call sites under internal/tools/).
A get result can attach an additional content block of type resource (mcp.EmbeddedResource) carrying the canonical MCP resource URI for the entity returned. This lets clients that only render Content blocks (and ignore StructuredContent) still surface a stable, dereferenceable identifier the user or LLM can pass to resources/read, follow-up tool calls, or UI deep-links.
An action declares the resource it returns on its ActionSpec: EmbeddedResourcePolicy (none, optional or always) says whether to embed, and EmbeddedResource is the canonical gitlab:// URI template, written with the action's own parameter names. The catalog validator refuses a template whose variables the action does not accept, and the dispatchers of all three surfaces expand the template from the call's parameters after a successful result, so a get embeds the same block whether it was reached as an individual tool, through a meta-tool action, or through gitlab_execute_action. Values are escaped the way RFC 6570 simple expansion escapes them, every character outside A-Z a-z 0-9 - . _ ~ percent-encoded, so project_id: "group/project" lands as gitlab://project/group%2Fproject and a scoped label priority::high as priority%3A%3Ahigh, the form the resource templates accept and a resources/read of that URI resolves. The template is expanded from the parameters as the handler read them, after the same normalisation: a project sent URL-encoded (group%2Fproject) embeds the same URI as one sent plainly on every surface, and one sent through a compatibility alias (project_path) does so wherever the surface accepts the alias, which is the dynamic surface and the meta surface under the opaque or compact parameter schema. An individual tool, and a meta tool under the full schema, refuses a property its schema does not declare before the call reaches the handler, so the alias embeds nothing there because it is never served. The twenty-two actions that declare one (TestEmbeddedResource_EveryGetThatHasAResourceDeclaresIt pins the list):
| Action | Canonical URI |
|---|---|
project.get |
gitlab://project/{project_id} |
project.board_get |
gitlab://project/{project_id}/board/{board_id} |
project.label_get |
gitlab://project/{project_id}/label/{label_id} |
project.milestone_get |
gitlab://project/{project_id}/milestone/{milestone_iid} |
group.get |
gitlab://group/{group_id} |
group.group_label_get |
gitlab://group/{group_id}/label/{label_id} |
group.group_milestone_get |
gitlab://group/{group_id}/milestone/{milestone_iid} |
issue.get |
gitlab://project/{project_id}/issue/{issue_iid} |
merge_request.get |
gitlab://project/{project_id}/mr/{merge_request_iid} |
pipeline.get |
gitlab://project/{project_id}/pipeline/{pipeline_id} |
job.get |
gitlab://project/{project_id}/job/{job_id} |
branch.get |
gitlab://project/{project_id}/branch/{branch_name} |
tag.get |
gitlab://project/{project_id}/tag/{tag_name} |
release.get |
gitlab://project/{project_id}/release/{tag_name} |
repository.commit_get |
gitlab://project/{project_id}/commit/{sha} |
environment.get |
gitlab://project/{project_id}/environment/{environment_id} |
environment.deployment_get |
gitlab://project/{project_id}/deployment/{deployment_id} |
feature_flags.feature_flag_get |
gitlab://project/{project_id}/feature_flag/{name} |
access.deploy_key_get |
gitlab://project/{project_id}/deploy_key/{deploy_key_id} |
wiki.get |
gitlab://project/{project_id}/wiki/{slug} |
snippet.get |
gitlab://snippet/{snippet_id} |
snippet.project_get |
gitlab://project/{project_id}/snippet/{snippet_id} |
The embedded resource carries MIMEType: "application/json" and a Text payload equal to the JSON-marshaled output struct — duplicating StructuredContent so simpler clients lose nothing. Error and not-found responses do not embed (the entity does not exist), and neither does a call that omitted one of the template's parameters.
This behaviour is enabled by default and can be disabled globally with GITLAB_MCP_EMBEDDED_RESOURCES=false (env var, both transports) or --embedded-resources=false (HTTP-mode flag) as a kill-switch for clients that don't tolerate duplicate content blocks.
Meta-tools declare a single tool-level OutputSchema (the envelope with next_steps and pagination fields). In addition, each action route can carry its own output schema describing the exact shape returned by that specific action.
Per-route schemas are populated automatically when using typed route constructors (RouteAction[T,R], DestructiveAction[T,R], RouteActionWithRequest[T,R], DestructiveActionWithRequest[T,R]) and the typed void/delete constructors (RouteVoidAction[T], DestructiveVoidAction[T], DestructiveVoidActionWithRequest[T]). Plain untyped Route() calls do not automatically get per-route schemas.
These schemas are:
- Exposed in
llms-full.txtunder "Action Output Schemas" for each meta-tool, using collapsible<details>blocks per action - Audited by
cmd/audit_surface_quality -view=outputwhich reports routes with missing schemas (categoryroute-output-schema) - Accessible programmatically via
tools.BuildActionCatalog(...); meta-tools, dynamic tools, thegitlab://toolsmanifest, and audits project route maps from that canonical catalog - Cached by
reflect.Typeto avoid redundant schema generation
This enables LLMs to predict the exact response shape of each meta-tool action without trial-and-error.
- Architecture — Response Format — implementation patterns for dual-output
- Meta-Tools — how domain meta-tools use this format
- Tools Reference — per-domain tool documentation
- Troubleshooting — common output format issues
- MCP Specification — Annotations