| name | create-mcp-tool |
| description | Create 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. |
Create MCP Tool — GitLab
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-go library supports the endpoint — if not, consider the upstream-contribution skill
- Decide the domain name (e.g.,
tags, branches, pipelines)
File Structure
Create a new sub-package under internal/tools/{domain}/:
{domain}/
├── {domain}.go # Input/Output structs + handler logic
├── action_specs.go # Canonical ActionSpec route metadata
├── markdown.go # Markdown formatters + init() registry
└── {domain}_test.go # Table-driven tests with httptest
Step 1: Define Input/Output Structs
In {domain}.go:
package {domain}
import "github.com/jmrplens/gitlab-mcp-server/v2/internal/toolutil"
type ListInput struct {
toolutil.PaginationInput
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"`
}
type ListOutput struct {
toolutil.HintableOutput
Items []Output `json:"items"`
Pagination toolutil.PaginationOutput `json:"pagination"`
}
Rules:
- Embed
toolutil.HintableOutput as first field (enables next_steps in JSON)
- Embed
toolutil.PaginationInput for list operations
- Use
toolutil.StringOrInt for project/group IDs
- 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:
func List(ctx context.Context, client *gitlabclient.Client, input ListInput) (ListOutput, error) {
opts := &gl.ListXxxOptions{
ListOptions: gl.ListOptions{
Page: input.Page(),
PerPage: input.PerPage(),
},
}
items, resp, err := client.GL().Xxx.ListXxx(input.ProjectID.String(), opts, gl.WithContext(ctx))
if err != nil {
return ListOutput{}, toolutil.WrapErrWithMessage("xxxList", err)
}
out := ListOutput{
Items: convertItems(items),
Pagination: toolutil.BuildPagination(resp),
}
return out, nil
}
func Create(ctx context.Context, client *gitlabclient.Client, input CreateInput) (Output, error) {
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 only
WrapErrWithMessage(op, err) — mutating operations (extracts GitLab error detail)
WrapErrWithHint(op, err, hint) — when a recovery action is known
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/v2/internal/gitlab"
"github.com/jmrplens/gitlab-mcp-server/v2/internal/toolutil"
)
func ActionSpecs(client *gitlabclient.Client) []toolutil.ActionSpec {
return []toolutil.ActionSpec{
toolutil.NewActionSpec("list", toolutil.RouteAction(client, List), actionOptions("gitlab_{domain}_list", true)),
toolutil.NewActionSpec("create", toolutil.RouteAction(client, Create), actionOptions("gitlab_{domain}_create", false)),
}
}
func actionOptions(individualTool string, readOnly bool) toolutil.ActionSpecOptions {
return toolutil.ActionSpecOptions{
ReadOnly: readOnly,
Idempotent: readOnly,
Tags: []string{"{domain}"},
OwnerPackage: "{domain}",
IndividualTool: toolutil.IndividualToolSpec{Name: individualTool, Title: toolutil.TitleFromName(individualTool)},
}
}
Spec rules:
- Set
ReadOnly, Destructive, and Idempotent accurately; destructive actions must use destructive route helpers or options.
- Set
OwnerPackage to the sub-package name.
- Set
IndividualTool so TOOL_SURFACE=individual can project the visible per-action tool.
- Add compatibility aliases and parameter aliases through the approved
actioncompat policy 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) on each spec; pruneSchemaFieldsByTier in internal/tools/action_catalog.go drops premium/ultimate-only actions and prunes per-field schema entries once GITLAB_TIER / --tier resolves.
- Fill discovery metadata (
Aliases, Usage, ParameterGuidance, RelatedActions) on every spec; go run ./cmd/audit_discovery_completeness/ is the gate (link-create-batch is the gold standard).
Step 4: Markdown Formatters
In markdown.go:
package {domain}
import (
"fmt"
"strings"
"github.com/jmrplens/gitlab-mcp-server/v2/internal/toolutil"
)
func init() {
toolutil.RegisterMarkdown(FormatOutputMarkdownString)
toolutil.RegisterMarkdown(FormatListMarkdownString)
}
func FormatOutputMarkdownString(out Output) string {
return FormatOutputMarkdown(out)
}
func FormatOutputMarkdown(out Output) string {
var sb strings.Builder
fmt.Fprintf(&sb, "# %s\n\n", out.Name)
fmt.Fprintf(&sb, "| Field | Value |\n")
sb.WriteString(toolutil.TableSep2 + "\n")
fmt.Fprintf(&sb, "| ID | %d |\n", out.ID)
fmt.Fprintf(&sb, "| Name | %s |\n", out.Name)
toolutil.WriteHints(&sb,
"Use gitlab_{domain}_update to modify this resource",
"Use gitlab_{domain}_delete to remove it",
)
return sb.String()
}
func FormatListMarkdownString(out ListOutput) string {
return FormatListMarkdown(out)
}
func FormatListMarkdown(out ListOutput) string {
var sb strings.Builder
sb.WriteString("# {Resources}\n\n")
if len(out.Items) == 0 {
sb.WriteString("No items found.\n")
return sb.String()
}
sb.WriteString("| ID | Name |\n")
sb.WriteString(toolutil.TableSep2 + "\n")
for _, item := range out.Items {
fmt.Fprintf(&sb, "| %d | %s |\n", item.ID, item.Name)
}
toolutil.WriteHints(&sb,
toolutil.HintPreserveLinks,
"Use gitlab_{domain}_get with ID for details",
)
return sb.String()
}
Rules:
- Register all formatters in
init() via toolutil.RegisterMarkdown
HintPreserveLinks as first hint in list formatters with clickable links
- Markdown tables use
toolutil.TableSep2, TableSep3, etc.
- 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.
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-first
go test ./internal/tools -run 'TestActionSpecCoverage|TestRegisterAllDoesNotUseDomainRegisterTools' -count=1
go test ./internal/tools/{domain}/ -count=1
Step 6: Write Tests
In {domain}_test.go:
package {domain}
import (
"context"
"net/http"
"testing"
"github.com/jmrplens/gitlab-mcp-server/v2/internal/testutil"
)
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) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"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 path
Test{Tool}_EmptyProjectID — input validation
Test{Tool}_APIError — error classification
TestFormat{X}Markdown_* — markdown output
TestFormat{X}Markdown_Empty — empty state
Step 7: Update Documentation
- Add or update
docs/tools/{domain}.md
- Update
docs/reference/tools/README.md only when the domain index changes
- At the end of the tool implementation phase, run
go run ./cmd/gen_testing_docs/ to refresh docs/development/testing/testing.md with new test counts and coverage values
Step 8: 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}/
Validation Checklist