agentleFS
jmrplensjmrplens/gitlab-mcp-server/.github/skills/create-mcp-tool/SKILL.md

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

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
  1. Very specific:

    Create a new sub-package under internal/tools/{domain}/:

    line 18
  2. Very specific:

    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.

    line 35
  3. Very specific:

    Use toolutil.StringOrInt for project/group IDs

    line 76
  4. Very specific:

    Use json:",omitempty" for optional fields

    line 79
  5. Distinctive:

    No domain prefix on type names — the package provides namespace

    line 82
  6. Very specific:

    Add compatibility aliases and parameter aliases through the approved actioncompat policy when historical names must keep working.

    line 214
  7. Very specific:

    Do not add package-local RegisterTools functions or package-level RegisterMeta calls for ordinary GitLab API actions.

    line 289
  8. Distinctive:

    Test categories (all required):

    line 375
  9. Distinctive:

    Test rules that CI gates:

    line 383
  10. Very specific:

    Never t.Fatal/t.Fatalf/t.FailNow inside an httptest handler or any other goroutine: t.Errorf + a deterministic response + return, or record …

    line 387

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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}/
├── 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.HintableOutput as first field (enables next_steps in JSON)
  • Embed toolutil.PaginationInput for list operations, and toolutil.KeysetPaginationInput beside it when the GitLab endpoint supports keyset pagination; such a list also passes toolutil.KeysetContinuation(input.KeysetPaginationInput) on its SDK call, last, after gl.WithContext(ctx) (it makes the token the whole query, so an option after it would add to GitLab's link), or the next_page_token its keyset pages publish cannot be followed (testutil.KeysetListing and testutil.FollowKeyset test it). A list whose route GitLab pages by offset alone embeds toolutil.PaginationInput only and passes a zero toolutil.KeysetPaginationInput{} to ApplyListOptions: R-PAGE's keyset half (make audit-1to1-paths) fails an action that offers page_token where no route it sends can be paged by keyset, as the live record says
  • Use toolutil.StringOrInt for project/group IDs
  • Tag fields that only exist at a higher GitLab tier with tier:"premium" or tier:"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 (omitempty no longer decides it), and make audit-1to1-required fails on a marker the live record disagrees with unless cmd/audit_1to1/internal/required/declarations.go says why. A choice between fields GitLab states as at_least_one_of is an anyOf through toolutil.SchemaAnyOfRequired in the spec's InputSchemaOverrides, and one it states as exactly_one_of a oneOf through toolutil.SchemaOneOfRequired, with neither field marked; only the SDK's schema validation holds a call to one oneOf branch, so the handler of an exactly_one_of also refuses a call naming two of the fields before it sends anything, as groupldap's do. A rule on each element of a list is an items override on that list (toolutil.SchemaItemsOneOfRequired, SchemaItemsAnyOfRequired, SchemaItemsAnyOfRequiredUnlessTrue and SchemaItemsAtMostOneOf, built on toolutil.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 through toolutil.CheckEnvironmentProtect and CheckEnvironmentUpdate
  • Use json:",omitempty" for optional fields
  • A membership access level parameter publishes the levels its route accepts and no other, from toolutil.AccessLevelSet in internal/toolutil/access_level.go (ProjectAccessLevels, GroupAccessLevels and the rest, read from GitLab's routes and models): SchemaOverride (or SchemaItemsOverride for a list) in the spec's InputSchemaOverrides makes it an integer enum, and its jsonschema description lists the same levels as N=Role. min_access_level and shared_min_access_level get theirs centrally. TestBuildActionCatalog_MembershipAccessLevels_OfferOnlyWhatTheRouteAccepts in internal/tools fails 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": a format through toolutil.SchemaFormatOverride (dates, URLs and email get theirs centrally, on a plain string only, so a nullable or nested one is overridden per action), or a pattern through toolutil.SchemaPatternOverride, with toolutil.CronPattern for a cron expression and toolutil.FullCommitSHAPattern for 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.SchemaNoFormatOverride keeps a central one off it. TestFind_FormatBearingParams_DeclareTheirFormatAndExampleIt in internal/tools/dynamic holds 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 only
  • WrapErrWithMessage(op, err) — mutating operations (extracts GitLab error detail)
  • WrapErrWithHint(op, err, hint) — when a recovery action is known
  • WrapErrWithStatusHint(op, err, code, hint) — when the hint applies to one HTTP status only (IsHTTPStatus + WrapErrWithHint in one call)
  • NotFoundResult(resource, identifier, hints...) — in get handlers on IsHTTPStatus(err, 404): an informational IsError result logged at INFO, with nil error
  • IsPermissionRefusal(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_id error

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, or NewDeleteActionSpec; each presets ReadOnly, Destructive, and Idempotent. A destructive action's route must be built with toolutil.DestructiveAction (returns an output) or toolutil.DestructiveVoidAction (returns only an error), which gate execution behind confirmation.
  • Set OwnerPackage to the sub-package name.
  • Set IndividualTool so GITLAB_MCP_TOOL_SURFACE=individual can 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 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; empty means free) on each spec. In internal/tools/action_catalog.go, filterActionSpecGroupsByTier withholds an action whose edition exceeds the tier resolved from GITLAB_MCP_TIER / --tier, and pruneSchemaFieldsByTier removes the tier:"..."-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_batch is 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() via toolutil.RegisterMarkdown (RegisterMarkdownPair / RegisterMarkdownTriple bundle several); TestAllMarkdownFormattersRegistered in internal/tools checks every output type has one, and holds the registry figures CLAUDE.md states, naming the sentence to write when a type is added or removed
  • HintPreserveLinks as 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 a gitlab_* tool name, and so does every other sentence a model reads: an error's message, a refusal, a Usage line, parameter guidance and a field's description, in a jsonschema tag or a schema override map. A tool name is right on one surface of three, and make check-action-ids fails on it. A bare meta action name (Use action 'list') resolves on the meta surface alone and no gate reads it, so write toolutil.HintAction("package.list", ...) instead
  • Markdown table separator rows come from toolutil.MarkdownTableSeparator(columns); single-record fields use the toolutil.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-first
  • make check-action-catalog-manifest
  • go test ./internal/tools -run 'TestActionSpecCoverage|TestRegisterAllDoesNotUseDomainRegisterTools|TestAllMarkdownFormattersRegistered' -count=1
  • go 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 path
  • Test{Tool}_EmptyProjectID — input validation
  • Test{Tool}_APIError — error classification
  • TestFormat{X}Markdown_* — markdown output
  • TestFormat{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 under t.Run (make check-test-subtests; go run ./cmd/audit_test_subtests/ -fix rewrites the unambiguous shapes)
  • Never t.Fatal/t.Fatalf/t.FailNow inside an httptest handler 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 / AssertQueryParam and testutil.ForbiddenHandler already follow the contract
  • A _test.go file 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 closed httptest server, 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.

  1. Pick the package by what the action needs of the instance: common for an action any GitLab serves, ce for one only an unlicensed instance does, ee for Premium or Ultimate
  2. Declare the action ID as a typed harness.ActionID constant in that package's actions_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
  3. Drive it with harness.Do for a success, harness.Refused or harness.ExpectToolError for a refusal whose message is the subject, and harness.Try when both halves are
  4. 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
  5. An action nothing on a Docker instance can run goes in cmd/audit_e2e_coverage/exemptions.go with 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

  1. Run make gen-tool-reference: the site's per-domain tool reference (site/src/content/docs/reference/tools/ and its es/ 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
  2. A new catalog group stops that generator until cmd/gen_tool_reference/domains.json gives 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
  3. Run make gen-site-stats: the tool, meta-tool and action counts the site prints come from site/src/data/stats.json, never from a figure written into a page. Run make gen-action-grants too: the fine-grained table (internal/tools/actiongrants/table_gen.go and the site's reference/fine-grained-permissions.mdx) holds one row per action, and make check-action-grants fails until it is regenerated. make update-all runs every generator that needs no measurement, GitLab instance or paid run, these two, make gen-tool-reference, make gen-llms and make gen-footprint among them
  4. 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. The update-starlight-docs skill maps changes to pages
  5. 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 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-stats

Validation Checklist

  • Sub-package created with doc.go, the handler file, action_specs.go, markdown.go, and the test file
  • Input structs use jsonschema tags with descriptions
  • Output structs embed toolutil.HintableOutput
  • Correct New*ActionSpec constructor and route helper per operation type
  • Markdown formatters registered in init()
  • Empty state handled in list formatters
  • HintPreserveLinks in 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.ActionID constant, or an exemption declares why none can, and make check-e2e-static passes
  • No limit on a caller (a rate, a ceiling, a refusal with -42900 or -50300) is added outside the tenant policy register, and make check-tenancy passes
  • go test + golangci-lint pass
  • 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

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.