create-mcp-tool
Skill47 starsChanged 6 months agoMITView on GitHub
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.
2 other projects use a version of this.
Adopted by
Most recently taken up by
- nickytonline/beads-mcptheir own version, first seen Oct 11, 2026
- jmrplens/libgen-mcptheir own version, first seen Aug 21, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- jmrplens/libgen-mcp★ 31same name
- nickytonline/beads-mcp★ 3same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository22 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- .github/copilot-instructions.mdCopilot instructions
- llms-full.txtllms.txt
- llms.txtllms.txt
- create-architectural-decision-recordSkill
- create-implementation-planSkill
- create-mcp-evaluationSkill
- create-specificationSkill
- generate-project-documentationSkill
- generate-release-notesSkill
- git-commitSkill
- golang-patternsSkill
- golang-testingSkill
- go-safe-move-refactorSkill
- go-source-documentationSkill
- increase-test-coverageSkill
- modularize-go-packageSkill
- review-and-refactorSkill
- update-project-documentationSkill
- update-starlight-docsSkill
- upstream-contributionSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 3 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 18
Create a new sub-package under internal/tools/{domain}/:
- Very specific: line 35
Every file opens with the copyright and license header and a blank line, as in the templates below, doc.go and the test files included.
- Very specific: line 76
Use toolutil.StringOrInt for project/group IDs
- Very specific: line 79
Use json:",omitempty" for optional fields
- Distinctive: line 82
No domain prefix on type names — the package provides namespace
- Very specific: line 214
Add compatibility aliases and parameter aliases through the approved actioncompat policy when historical names must keep working.
- Very specific: line 289
Do not add package-local RegisterTools functions or package-level RegisterMeta calls for ordinary GitLab API actions.
- Distinctive: line 375
Test categories (all required):
- Distinctive: line 383
Test rules that CI gates:
- Very specific: line 387
Never t.Fatal/t.Fatalf/t.FailNow inside an httptest handler or any other goroutine: t.Errorf + a deterministic response + return, or record …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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-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.
Every file opens with the copyright and license header and a blank line, as in
the templates below, doc.go and the test files included; make
check-file-headers fails a file without it and make fix-file-headers writes
it (Copyright and License Headers).
Step 1: Define Input/Output Structs
In {domain}.go:
// Copyright (c) 2026 José M. Requena Plens
// SPDX-License-Identifier: MIT
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; such a list also passestoolutil.KeysetContinuation(input.KeysetPaginationInput)on its SDK call, last, aftergl.WithContext(ctx)(it makes the token the whole query, so an option after it would add to GitLab's link), or thenext_page_tokenits keyset pages publish cannot be followed (testutil.KeysetListingandtestutil.FollowKeysettest it). A list whose route GitLab pages by offset alone embedstoolutil.PaginationInputonly and passes a zerotoolutil.KeysetPaginationInput{}toApplyListOptions: R-PAGE's keyset half (make audit-1to1-paths) fails an action that offerspage_tokenwhere no route it sends can be paged by keyset, as the live record says - 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 a field GitLab requires on the route the action sends, and only for that: the marker is the one required list all three surfaces serve (omitemptyno longer decides it), andmake audit-1to1-requiredfails on a marker the live record disagrees with unlesscmd/audit_1to1/internal/required/declarations.gosays why. A choice between fields GitLab states asat_least_one_ofis ananyOfthroughtoolutil.SchemaAnyOfRequiredin the spec'sInputSchemaOverrides, and one it states asexactly_one_ofaoneOfthroughtoolutil.SchemaOneOfRequired, with neither field marked; only the SDK's schema validation holds a call to oneoneOfbranch, so the handler of anexactly_one_ofalso refuses a call naming two of the fields before it sends anything, asgroupldap's do. A rule on each element of a list is an items override on that list (toolutil.SchemaItemsOneOfRequired,SchemaItemsAnyOfRequired,SchemaItemsAnyOfRequiredUnlessTrueandSchemaItemsAtMostOneOf, built ontoolutil.SchemaItemsOverride), and since neither the meta nor the dynamic dispatcher reads an item schema, the handler refuses an element that breaks it, as the protected environment protect and update do throughtoolutil.CheckEnvironmentProtectandCheckEnvironmentUpdate - Use
json:",omitempty"for optional fields - A membership access level parameter publishes the levels its route accepts and no other, from
toolutil.AccessLevelSetininternal/toolutil/access_level.go(ProjectAccessLevels,GroupAccessLevelsand the rest, read from GitLab's routes and models):SchemaOverride(orSchemaItemsOverridefor a list) in the spec'sInputSchemaOverridesmakes it an integer enum, and itsjsonschemadescription lists the same levels asN=Role.min_access_levelandshared_min_access_levelget theirs centrally.TestBuildActionCatalog_MembershipAccessLevels_OfferOnlyWhatTheRouteAcceptsininternal/toolsfails on such a parameter it does not map to a set, and on a description that offers a level outside it - A string whose form GitLab checks publishes that form, so a model knows what to send and find's example fills it with a value of it rather than
"value": aformatthroughtoolutil.SchemaFormatOverride(dates, URLs andemailget theirs centrally, on a plain string only, so a nullable or nested one is overridden per action), or apatternthroughtoolutil.SchemaPatternOverride, withtoolutil.CronPatternfor a cron expression andtoolutil.FullCommitSHAPatternfor a SHA where GitLab holds the parameter to a full one. A SHA that also takes a ref name, a duration, and a field cleared with an empty string get no pattern, since it would refuse values GitLab takes, and a string that takes several values in one, an invitation's comma-separated emails or an OAuth application's redirect URIs one per line, gets no format, since a format names one value:toolutil.SchemaNoFormatOverridekeeps a central one off it.TestFind_FormatBearingParams_DeclareTheirFormatAndExampleItininternal/tools/dynamicholds every required parameter of the catalog to it - 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.
// Copyright (c) 2026 José M. Requena Plens
// SPDX-License-Identifier: MIT
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:
// Copyright (c) 2026 José M. Requena Plens
// SPDX-License-Identifier: MIT
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, and holds the registry figuresCLAUDE.mdstates, naming the sentence to write when a type is added or removed 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:
// Copyright (c) 2026 José M. Requena Plens
// SPDX-License-Identifier: MIT
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) - An address nothing answers comes from
internal/testutil/netfixture(DeadEndpoint,NewStoppableServer), never from a closedhttptestserver, whose freed port another listener can be given (make check-closed-servers)
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
- Run
make gen-tool-reference: the site's per-domain tool reference (site/src/content/docs/reference/tools/and itses/twin) is generated from the catalog, one page per catalog group, so the new action appears on its group's page with its ID, tools, tier, annotations, description and parameters. Never edit those pages by hand - A new catalog group stops that generator until
cmd/gen_tool_reference/domains.jsongives it an index category, a title, a one-line description, an overview and sample questions, in English and Spanish; an existing group's overview is updated there when the new action changes what the group is for - Run
make gen-site-stats: the tool, meta-tool and action counts the site prints come fromsite/src/data/stats.json, never from a figure written into a page. Runmake gen-action-grantstoo: the fine-grained table (internal/tools/actiongrants/table_gen.goand the site'sreference/fine-grained-permissions.mdx) holds one row per action, andmake check-action-grantsfails until it is regenerated.make update-allruns every generator that needs no measurement, GitLab instance or paid run, these two,make gen-tool-reference,make gen-llmsandmake gen-footprintamong them - Hand-written site pages change only where the action changes what a user reads there, and then in English and Spanish together: the per-meta-tool action count table of
tools/meta-tools, or the page of a capability or setting the action introduces. Theupdate-starlight-docsskill maps changes to pages - 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-closed-servers check-e2e-static
make check-tool-reference check-site-statsValidation 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-50300) is added outside the tenant policy register, andmake check-tenancypasses go test+golangci-lintpass- Documentation updated: the tool reference regenerated (
make gen-tool-reference), the site's counts refreshed (make gen-site-stats), and any hand-written site page the action changes edited in English and Spanish
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

