Create MCP Tool — GitLab
SkillDocs & knowledgeCreate a new MCP tool end-to-end: sub-package, input/output structs, handler, ActionSpec metadata, markdown formatter, tests, catalog projection, and documentation. Use when adding a new GitLab API endpoint as an MCP tool.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Create MCP Tool skill
What this skill tells your AI
The instructions your AI receives, as published by jmrplens/gitlab-mcp-server in .github/skills/create-mcp-tool/SKILL.md and read by ahel’s review.
Step-by-step workflow for creating a new MCP tool that wraps a GitLab REST/GraphQL API endpoint.
Prerequisites
- Identify the GitLab API endpoint(s) (REST v4 or GraphQL)
- Confirm the
client-golibrary supports the endpoint — if not, consider theupstream-contributionskill - Decide the domain name (e.g.,
tags,branches,pipelines)
File Structure
Create a new sub-package under internal/tools/{domain}/:
{domain}/
├── doc.go # Package comment (the one `// Package {domain} ...` comment)
├── {domain}.go # Input/Output structs + handler logic
├── action_specs.go # Canonical ActionSpec route metadata
├── markdown.go # Markdown formatters + init() registry
├── shapes.go # Optional: nested output shapes mirrored from client-go sub-objects
└── {domain}_test.go # Tests with httptest (plus action_specs_test.go, markdown_test.go as needed)
A multi-word domain separates the words with underscores in its file names
only: merge_requests.go and merge_requests_test.go in package mergerequests.
Go's convention and the stylecheck/revive naming rules refuse underscores in
a package identifier, and the directory follows the package.
Step 1: Define Input/Output Structs
In {domain}.go:
package {domain}
import "github.com/jmrplens/gitlab-mcp-server/v3/internal/toolutil"
type ListInput struct {
toolutil.PaginationInput
toolutil.KeysetPaginationInput // only when the endpoint supports keyset pagination
ProjectID toolutil.StringOrInt `json:"project_id" jsonschema:"Project ID or URL-encoded path,required"`
}
type Output struct {
toolutil.HintableOutput
ID int `json:"id"`
Name string `json:"name"`
WebURL string `json:"web_url"`
}
type ListOutput struct {
toolutil.HintableOutput
Items []Output `json:"items"`
Pagination toolutil.PaginationOutput `json:"pagination"`
}
Rules:
- Embed
toolutil.HintableOutputas first field (enablesnext_stepsin JSON) - Embed
toolutil.PaginationInputfor list operations, andtoolutil.KeysetPaginationInputbeside it when the GitLab endpoint supports keyset pagination - Use
toolutil.StringOrIntfor project/group IDs - Tag fields that only exist at a higher GitLab tier with
tier:"premium"ortier:"ultimate"; the catalog prunes them from the schema below that tier - Use
jsonschema:"description,required"for required fields - Use
json:",omitempty"for optional fields - No domain prefix on type names — the package provides namespace
Step 2: Implement Handler Functions
In {domain}.go:
import (
"context"
"errors"
gl "gitlab.com/gitlab-org/api/client-go/v3"
gitlabclient "github.com/jmrplens/gitlab-mcp-server/v3/internal/gitlab"
"github.com/jmrplens/gitlab-mcp-server/v3/internal/toolutil"
)
func List(ctx context.Context, client *gitlabclient.Client, input ListInput) (ListOutput, error) {
if err := ctx.Err(); err != nil {
return ListOutput{}, err
}
if input.ProjectID == "" {
return ListOutput{}, errors.New("xxxList: project_id is required. Use project.list to find the ID first")
}
opts := &gl.ListXxxOptions{}
toolutil.ApplyListOptions(&opts.ListOptions, input.PaginationInput, input.KeysetPaginationInput)
// gl.WithContext(ctx) is required on every client-go call: without it the
// SDK builds the request from context.Background(), so neither the action
// deadline nor an abandoned call can end it. make check-sdk-context gates it.
items, resp, err := client.GL().Xxx.ListXxx(input.ProjectID.String(), opts, gl.WithContext(ctx))
if err != nil {
return ListOutput{}, toolutil.WrapErr("xxxList", err)
}
out := ListOutput{
Items: convertItems(items),
Pagination: toolutil.PaginationFromResponse(resp),
}
return out, nil
}
func Create(ctx context.Context, client *gitlabclient.Client, input CreateInput) (Output, error) {
if err := ctx.Err(); err != nil {
return Output{}, err
}
if input.ProjectID == "" {
return Output{}, errors.New("xxxCreate: project_id is required")
}
opts := &gl.CreateXxxOptions{
Name: gl.Ptr(input.Name),
}
item, _, err := client.GL().Xxx.CreateXxx(input.ProjectID.String(), opts, gl.WithContext(ctx))
if err != nil {
switch {
case toolutil.ContainsAny(err, "already exists"):
return Output{}, toolutil.WrapErrWithHint("xxxCreate", err,
"a resource with this name already exists")
default:
return Output{}, toolutil.WrapErrWithMessage("xxxCreate", err)
}
}
return convertItem(item), nil
}
Error handling rules:
WrapErr(op, err)— read-only operations onlyWrapErrWithMessage(op, err)— mutating operations (extracts GitLab error detail)WrapErrWithHint(op, err, hint)— when a recovery action is knownWrapErrWithStatusHint(op, err, code, hint)— when the hint applies to one HTTP status only (IsHTTPStatus+WrapErrWithHintin one call)NotFoundResult(resource, identifier, hints...)— in get handlers onIsHTTPStatus(err, 404): an informationalIsErrorresult logged at INFO, withnilerrorIsPermissionRefusal(err)+WrapErrWithHint: for a hint that names a role, a license or an owner. GitLab refuses a missing permission with 401 at many routes, so do not scope such a hint to 403, and do not key it on 401 alone, which also matches a token GitLab rejected- Validate required inputs before calling GitLab and check
ctx.Err()first, as the real handlers do (internal/tools/branches/branches.go); the tests below expect the empty-project_iderror
Step 3: Add ActionSpecs
In action_specs.go, define the canonical route metadata once. Meta-tools, dynamic find/execute, gitlab://tools resources, audits, and individual tool projection consume this spec.
package {domain}
import (
gitlabclient "github.com/jmrplens/gitlab-mcp-server/v3/internal/gitlab"
"github.com/jmrplens/gitlab-mcp-server/v3/internal/toolutil"
)
// ActionSpecs returns canonical specs for {domain} actions.
func ActionSpecs(client *gitlabclient.Client) []toolutil.ActionSpec {
return []toolutil.ActionSpec{
// gitlab_{domain}_list: read-only, idempotent.
toolutil.NewReadActionSpec("list", toolutil.RouteAction(client, List), listOptions()),
// gitlab_{domain}_create: mutating, not idempotent.
toolutil.NewCreateActionSpec("create", toolutil.RouteAction(client, Create), createOptions()),
// gitlab_{domain}_delete: destructive, the route asks for confirmation before running.
toolutil.NewDeleteActionSpec("delete", toolutil.DestructiveVoidAction(client, Delete), deleteOptions()),
}
}
func listOptions() toolutil.ActionSpecOptions {
return toolutil.ActionSpecOptions{
Aliases: []string{"gitlab_{domain}_list", "list {resources}", "show {resources}"},
Usage: "List {resources} of a project. Use after locating the project with project.get, then {domain}.get for one item's details.",
Tags: []string{"{domain}"},
ParameterGuidance: map[string]toolutil.ParameterGuidance{
"project_id": {SemanticRole: "project", ValueSource: "Project that owns the {resources}."},
},
RelatedActions: []string{"{domain}.get", "{domain}.create", "project.get"},
OpenWorld: true,
OwnerPackage: "{domain}",
IndividualTool: toolutil.IndividualToolSpec{
Name: "gitlab_{domain}_list",
Title: toolutil.TitleFromName("gitlab_{domain}_list"),
Description: "List {resources} of a project. Returns: ... See also: gitlab_{domain}_get, gitlab_{domain}_create.",
},
}
}
createOptions and deleteOptions follow the same shape. A complete, current example of all four buckets is internal/tools/issuelinks/action_specs.go.
Spec rules:
- Pick the constructor that matches the operation:
NewReadActionSpec,NewCreateActionSpec,NewUpdateActionSpec,NewAdditiveActionSpec, orNewDeleteActionSpec; each presetsReadOnly,Destructive, andIdempotent. A destructive action's route must be built withtoolutil.DestructiveAction(returns an output) ortoolutil.DestructiveVoidAction(returns only an error), which gate execution behind confirmation. - Set
OwnerPackageto the sub-package name. - Set
IndividualToolsoGITLAB_MCP_TOOL_SURFACE=individualcan project the visible per-action tool; the name is declared here, never derived, and new actions take the domain-first form (gitlab_{domain}_{action}). - Add compatibility aliases and parameter aliases through the approved
actioncompatpolicy when historical names must keep working. - New domains must be added through the catalog aggregation/generation path, not by hand-adding root runtime registration calls.
- Set
Edition(free/premium/ultimate; empty meansfree) on each spec. Ininternal/tools/action_catalog.go,filterActionSpecGroupsByTierwithholds an action whose edition exceeds the tier resolved fromGITLAB_MCP_TIER/--tier, andpruneSchemaFieldsByTierremoves thetier:"..."-tagged fields from the schemas. - Fill discovery metadata (
Aliases,Usage,ParameterGuidance,RelatedActions) on every spec;go run ./cmd/audit_discovery_completeness/is the gate (release.link_create_batchis the gold standard).
Step 4: Markdown Formatters
In markdown.go:
package {domain}
import (
"fmt"
"strings"
"github.com/jmrplens/gitlab-mcp-server/v3/internal/toolutil"
)
// FormatOutputMarkdown renders a single {resource} as Markdown.
func FormatOutputMarkdown(out Output) string {
if out.ID == 0 {
return ""
}
var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n\n", out.Name)
fmt.Fprintf(&sb, toolutil.FmtMdID, out.ID)
fmt.Fprintf(&sb, "- **Name**: %s\n", out.Name)
toolutil.WriteHints(&sb,
toolutil.HintAction("{domain}.update", "modify this resource"),
toolutil.HintAction("{domain}.delete", "remove it"),
)
return sb.String()
}
// FormatListMarkdown renders a list of {resources} as a Markdown table.
func FormatListMarkdown(out ListOutput) string {
if len(out.Items) == 0 {
return "No {resources} found.\n"
}
var sb strings.Builder
fmt.Fprintf(&sb, "## {Resources} (%d)\n\n", len(out.Items))
sb.WriteString("| ID | Name |\n")
sb.WriteString(toolutil.MarkdownTableSeparator(2))
for _, item := range out.Items {
fmt.Fprintf(&sb, "| %d | %s |\n", item.ID, toolutil.MdTitleLink(item.Name, item.WebURL))
}
toolutil.WriteHints(&sb,
toolutil.HintPreserveLinks,
toolutil.HintAction("{domain}.get", "see one in full, with its ID"),
)
return sb.String()
}
func init() {
toolutil.RegisterMarkdown(FormatOutputMarkdown)
toolutil.RegisterMarkdown(FormatListMarkdown)
}
Rules:
- Register all formatters in
init()viatoolutil.RegisterMarkdown(RegisterMarkdownPair/RegisterMarkdownTriplebundle several);TestAllMarkdownFormattersRegisteredininternal/toolschecks every output type has one HintPreserveLinksas first hint in list formatters with clickable links;toolutil.MdTitleLink(title, url)renders the link cell- A hint names an action by its canonical ID, through
toolutil.HintAction(id, purpose)or the ID itself, never by agitlab_*tool name, and so does every other sentence a model reads: an error's message, a refusal, aUsageline, parameter guidance and a field's description, in ajsonschematag or a schema override map. A tool name is right on one surface of three, andmake check-action-idsfails on it. A bare meta action name (Use action 'list') resolves on the meta surface alone and no gate reads it, so writetoolutil.HintAction("package.list", ...)instead - Markdown table separator rows come from
toolutil.MarkdownTableSeparator(columns); single-record fields use thetoolutil.FmtMd*format constants (FmtMdID, ...) - Empty state: always handle
len(items) == 0
Step 5: Wire Catalog Aggregation
For a new domain, add its ActionSpecs(client) builder to the audited catalog aggregation path used by BuildActionCatalog: in internal/tools/action_specs.go, append it inside the build*ActionSpecs function of the catalog group it belongs to (for example buildIssueActionSpecs appends issuelinks.ActionSpecs(client)... into the gitlab_issue group), or add a new group function and list it in CollectActionSpecs. Then regenerate the audited manifest with make gen-action-catalog-manifest (it rewrites internal/tools/action_specs_manifest_gen.go) and confirm it with make check-action-catalog-manifest.
Do not add package-local RegisterTools functions or package-level RegisterMeta calls for ordinary GitLab API actions. Root individual registration is catalog-backed through RegisterIndividualCatalogTools.
Expected checks:
make audit-catalog-firstmake check-action-catalog-manifestgo test ./internal/tools -run 'TestActionSpecCoverage|TestRegisterAllDoesNotUseDomainRegisterTools|TestAllMarkdownFormattersRegistered' -count=1go test ./internal/tools/{domain}/ -count=1
Step 6: Write Tests
In {domain}_test.go:
package {domain}
import (
"context"
"net/http"
"strings"
"testing"
"github.com/jmrplens/gitlab-mcp-server/v3/internal/testutil"
)
// TestList_Success verifies that List returns the items the mocked
// GET /api/v4/projects/42/{endpoint} endpoint serves.
func TestList_Success(t *testing.T) {
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/42/{endpoint}" {
testutil.RespondJSON(w, http.StatusOK, `[{"id":1,"name":"item1"}]`)
return
}
http.NotFound(w, r)
}))
out, err := List(context.Background(), client, ListInput{
ProjectID: "42",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Items) != 1 {
t.Errorf("got %d items, want 1", len(out.Items))
}
if out.Items[0].Name != "item1" {
t.Errorf("Name = %q, want %q", out.Items[0].Name, "item1")
}
}
func TestList_EmptyProjectID(t *testing.T) {
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
_, err := List(context.Background(), client, ListInput{})
if err == nil {
t.Fatal("expected error for empty project ID")
}
}
func TestCreate_APIError(t *testing.T) {
client := testutil.NewTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.RespondJSON(w, http.StatusForbidden, `{"message":"403 Forbidden"}`)
}))
_, err := Create(context.Background(), client, CreateInput{
ProjectID: "42",
Name: "test",
})
if err == nil {
t.Fatal("expected error for 403")
}
}
func TestFormatListMarkdown_Empty(t *testing.T) {
md := FormatListMarkdown(ListOutput{})
if !strings.Contains(md, "No items found") {
t.Error("empty list should show 'No items found'")
}
}
Test categories (all required):
Test{Tool}_Success— happy pathTest{Tool}_EmptyProjectID— input validationTest{Tool}_APIError— error classificationTestFormat{X}Markdown_*— markdown outputTestFormat{X}Markdown_Empty— empty state
Test rules that CI gates:
- Name tests
TestToolName_Scenario_ExpectedResult; every test function carries a doc comment - A case table (
for _, tt := range tests) must run each case undert.Run(make check-test-subtests;go run ./cmd/audit_test_subtests/ -fixrewrites the unambiguous shapes) - Never
t.Fatal/t.Fatalf/t.FailNowinside anhttptesthandler or any other goroutine:t.Errorf+ a deterministic response +return, or record with atomics and assert afterwards (.github/instructions/test-goroutines.instructions.md;make check-test-goroutines).testutil.AssertRequestPath/AssertRequestMethod/AssertQueryParamandtestutil.ForbiddenHandleralready follow the contract - A
_test.gofile is named after the module it tests (make check-test-file-names)
Step 7: Write the End-to-End Scenario
A new action needs a scenario under test/e2e/gitlab/, and this is a gate rather than a suggestion: make check-e2e-static fails on a catalog action no scenario names, and make analyze and CI's compile job both run it.
- Pick the package by what the action needs of the instance:
commonfor an action any GitLab serves,cefor one only an unlicensed instance does,eefor Premium or Ultimate - Declare the action ID as a typed
harness.ActionIDconstant in that package'sactions_test.go, beside the others. A string literal at the call site is invisible to the gate, which reads typed constants out of the type checker's record - Drive it with
harness.Dofor a success,harness.Refusedorharness.ExpectToolErrorfor a refusal whose message is the subject, andharness.Trywhen both halves are - Build what the scenario stands on with
test/e2e/internal/fixture, never with the server under test: fixture traffic counts as no coverage, and a broken tool then fails the test that exercises it rather than every test that needs a project - An action nothing on a Docker instance can run goes in
cmd/audit_e2e_coverage/exemptions.gowith a category and a reason. A declaration that stops describing the tree is itself a finding, so an exemption is a statement a reviewer can check rather than a way past the gate
make check-e2e-static # the push-time gate, no GitLab needed
go test -tags e2e -c -o /dev/null ./test/e2e/gitlab/...
Step 8: Update Documentation
- Add the tools to the page under
docs/reference/tools/that owns the domain (docs/reference/tools/doc-ownership.jsonmaps tool-name prefixes to pages, andgo run ./cmd/audit_doc_coverage/is the gate); create a new page and an ownership entry only for a new area - The catalog tables in
docs/reference/tools/README.mdare generator-owned; do not hand-edit them - At the end of the tool implementation phase, run
go run ./cmd/gen_testing_docs/to refreshdocs/development/testing/testing.mdwith new test counts and coverage values
Step 9: Verify
go test ./internal/tools/{domain}/ -count=1 -v
go run ./cmd/gen_testing_docs/ --check
npx markdownlint-cli2 docs/development/testing/testing.md
golangci-lint run --build-tags e2e ./internal/tools/{domain}/
make check-test-subtests check-test-goroutines check-test-file-names check-e2e-static
go run ./cmd/audit_doc_coverage/
Validation Checklist
- Sub-package created with
doc.go, the handler file,action_specs.go,markdown.go, and the test file - Input structs use
jsonschematags with descriptions - Output structs embed
toolutil.HintableOutput - Correct
New*ActionSpecconstructor and route helper per operation type - Markdown formatters registered in
init() - Empty state handled in list formatters
-
HintPreserveLinksin list formatters with links - Error handling uses correct WrapErr variant
- Added to ActionSpec/catalog aggregation and covered by
make audit-catalog-first - Tests cover success, validation, API error, and markdown
- An e2e scenario names the action through a typed
harness.ActionIDconstant, or an exemption declares why none can, andmake check-e2e-staticpasses - No limit on a caller (a rate, a ceiling, a refusal with
-42900or-32000) is added outside the tenant policy register, andmake check-tenancypasses -
go test+golangci-lintpass - Documentation updated
Signals
- GitHub stars
- 39
- Forks
- 5
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
create-mcp-tool- Source
- github.com/jmrplens/gitlab-mcp-server