Skip to content
1 change: 1 addition & 0 deletions .github/skills/create-mcp-tool/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Rules:
- 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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ go test ./internal/prompts/ -count=1 -v # Prompts

### Running E2E tests

The suite drives the **real `cmd/server` binary** over stdio against a real GitLab, so a tool call crosses a process boundary and then the network; read `test/e2e/README.md` before changing any of it. **The package decides the runtime, not a build tag**: `test/e2e/gitlab/common` for what any instance serves (175 test files), `ce` for what only an unlicensed one does (5), `ee` for Premium and Ultimate (48), all three compiled together under the one `e2e` tag. Every target stages the binary first, so none of them is a bare `go test`:
The suite drives the **real `cmd/server` binary** over stdio against a real GitLab, so a tool call crosses a process boundary and then the network; read `test/e2e/README.md` before changing any of it. **The package decides the runtime, not a build tag**: `test/e2e/gitlab/common` for what any instance serves (176 test files), `ce` for what only an unlicensed one does (6), `ee` for Premium and Ultimate (49), all three compiled together under the one `e2e` tag. Every target stages the binary first, so none of them is a bare `go test`:

```bash
make test-e2e # self-hosted, from .env (GITLAB_URL, GITLAB_TOKEN); a package the instance cannot serve skips
Expand Down
94 changes: 64 additions & 30 deletions cmd/audit_1to1/internal/enums/exemptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,33 @@ import "maps"
// while GitLab documents what each endpoint accepts, so the same type-wide
// constant is excused on many fields for the same documented reason.
const (
// The membership reasons below cite the routes and models at v19.5.0-ee,
// which register row 110 of docs/development/upstream-bugs.md records,
// rather than the API pages, which listed levels the routes refuse until
// the documentation change that row names. The schemas offer what
// toolutil.AccessLevelSet holds (issue 1280).
//
// membershipNoAdmin: 60 (Admin) is a constant on AccessLevelValue because
// a protected ref can be restricted to administrators, not because a
// membership can carry it.
membershipNoAdmin = "60 (Admin) is not a level a membership can carry: doc/api/project_members.md, group_members.md, invitations.md and access_requests.md list 0 (No access) through 50 (Owner) for adding, editing, inviting and approving; only the group member edit endpoint documents 60, and that field offers it"
membershipNoAdmin = "60 (Admin) is not a level a membership can carry: no member, invitation, access request, share or group link route accepts it at v19.5.0-ee, the group member update included (docs/development/upstream-bugs.md row 110)"
// membershipNoAccessGrant: 0 (No access) is a level a membership reads
// back with, not one the add and edit endpoints grant; the handlers treat
// 0 as the field left unset and refuse the request before it leaves.
membershipNoAccessGrant = "0 (No access) is read back on a membership but never granted: doc/api/members.md lists it under valid access levels while the add and edit handlers (members.go, group_members.go) treat 0 as access_level left unset and refuse the call"
// back with, not one any write grants.
membershipNoAccessGrant = "0 (No access) is read back on a membership but never granted: no write route accepts it at v19.5.0-ee, and an access request approval answers it with 201 while the request stays pending, so the approvals refuse it before the request (docs/development/upstream-bugs.md row 110)"
// projectNoMinimalAccess: Minimal access is a group level.
projectNoMinimalAccess = "5 (Minimal access) is refused on a project in every edition at v19.5.0-ee: a project member, invitation, share and approval are held to Gitlab::Access.all_values, Guest (10) to Owner (50) (docs/development/upstream-bugs.md row 110)"
// groupLinkNoAccess: a SAML or LDAP group link grants a role.
groupLinkNoAccess = "the SAML and LDAP group link routes declare values_with_minimal_access at v19.5.0-ee, Minimal access (5) to Owner (50), so 0 (No access) is refused though doc/api/saml.md and group_ldap_links.md list it (docs/development/upstream-bugs.md row 110)"
// memberRoleBaseLevels: a custom role is based on a role a member can
// hold, which leaves out both ends of AccessLevelValue and the minimal
// access level in between.
memberRoleBaseLevels = "doc/api/member_roles.md#add-a-member-role-to-an-instance lists 10 (Guest) through 50 (Owner) as the valid base access levels"
// broadcastTargetLevels: the roles a banner can target are the membership
// roles proper, without the two ends of AccessLevelValue.
broadcastTargetLevels = "doc/api/broadcast_messages.md lists target_access_levels as 10 (Guest), 15 (Planner), 20 (Reporter), 25 (Security Manager), 30 (Developer), 40 (Maintainer) and 50 (Owner); 5 (Minimal access) and 60 (Admin) are AccessLevelValue constants outside that set"
// minAccessLevelFilter: the list filters document the membership roles a
// caller can hold, which excludes both ends of the type.
minAccessLevelFilter = "doc/api/groups.md and projects.md document min_access_level and shared_min_access_level as 5 (Minimal access) through 50 (Owner): 0 (No access) filters nothing and 60 (Admin) is not a membership level"
// hold, below Owner.
memberRoleBaseLevels = "the create route declares Gitlab::Access.all_values and MemberRole validates the level against options_for_custom_roles at v19.5.0-ee, so Guest (10) to Maintainer (40) is what both accept: 0, 5 (Minimal access), 50 (Owner) and 60 (Admin) are each refused by one of the two, though doc/api/member_roles.md lists 50 (docs/development/upstream-bugs.md row 110)"
// broadcastTargetLevels: the roles a banner can target.
broadcastTargetLevels = "the routes declare target_access_levels with values: System::BroadcastMessage::ALLOWED_TARGET_ACCESS_LEVELS at v19.5.0-ee, Guest, Planner, Reporter, Developer, Maintainer and Owner; 0, 5 (Minimal access), 25 (Security Manager) and 60 (Admin) are AccessLevelValue constants outside it, though doc/api/broadcast_messages.md lists 25 (docs/development/upstream-bugs.md row 110)"
// minAccessLevelFilter: the list filters take the membership roles a
// caller can hold.
minAccessLevelFilter = "every min_access_level and shared_min_access_level route declares values: Gitlab::Access.all_values at v19.5.0-ee, Guest (10) to Owner (50) in every edition: 0 filters nothing, 5 (Minimal access) is refused and 60 (Admin) is not a membership level (docs/development/upstream-bugs.md row 110)"
// shareAccessLevel covers the two share endpoints, which grant a role.
shareAccessLevel = "doc/api/groups.md#share-groups-with-groups and projects.md#share-a-project-with-a-group list 5 (Minimal access) through 50 (Owner); 0 grants nothing and 60 (Admin) is not a membership level"
shareAccessLevel = "a group share declares values_with_minimal_access and a project share Gitlab::Access.all_values at v19.5.0-ee: 0 grants nothing, 60 (Admin) is not a membership level, and a project share refuses 5 (Minimal access) as well (docs/development/upstream-bugs.md row 110)"
// protectedBranchLevels is the four-value set every protection level
// parameter documents, against the membership-wide AccessLevelValue.
protectedBranchLevels = "doc/api/protected_branches.md#valid-access-levels lists 0 (No access, push and merge only), 30, 40 and 60 (GitLab Self-Managed only); AccessLevelValue is the membership-wide set, and the other levels are not protection levels"
Expand Down Expand Up @@ -64,7 +71,8 @@ const (
// a subset of the SDK's type-wide constants.
// - "<pkg>.<MCPType>.<tag>=<value>" — one value, missing or extra.
//
// Each entry carries a reason that cites the GitLab API doc, and an entry
// Each entry carries a reason that cites the GitLab API doc, or the route
// and model where the doc lists a value they refuse, and an entry
// that excuses nothing (the field is gone, or the gap closed) is a finding,
// so the table cannot outlive what it describes.
var acceptedEnumGaps = buildAcceptedEnumGaps()
Expand All @@ -75,50 +83,76 @@ var acceptedEnumGaps = buildAcceptedEnumGaps()
func buildAcceptedEnumGaps() map[string]string {
table := map[string]string{
// --- Membership access levels (AccessLevelValue) ---
"accessrequests.ApproveGroupInput.access_level=0": membershipNoAccessGrant,
"accessrequests.ApproveGroupInput.access_level=60": membershipNoAdmin,
"accessrequests.ApproveProjectInput.access_level=0": membershipNoAccessGrant,
"accessrequests.ApproveProjectInput.access_level=5": projectNoMinimalAccess,
"accessrequests.ApproveProjectInput.access_level=60": membershipNoAdmin,
"groupmembers.AddInput.access_level=60": membershipNoAdmin,
"groupmembers.EditInput.access_level=60": membershipNoAdmin,
"invites.GroupInvitesInput.access_level=0": membershipNoAccessGrant,
"invites.GroupInvitesInput.access_level=60": membershipNoAdmin,
"invites.ProjectInvitesInput.access_level=0": membershipNoAccessGrant,
"invites.ProjectInvitesInput.access_level=5": projectNoMinimalAccess,
"invites.ProjectInvitesInput.access_level=60": membershipNoAdmin,
"members.AddInput.access_level=5": projectNoMinimalAccess,
"members.AddInput.access_level=60": membershipNoAdmin,
"members.EditInput.access_level=5": projectNoMinimalAccess,
"members.EditInput.access_level=60": membershipNoAdmin,
"members.AddInput.access_level=0": membershipNoAccessGrant,
"members.EditInput.access_level=0": membershipNoAccessGrant,
"groupmembers.AddInput.access_level=0": membershipNoAccessGrant,
"groupmembers.EditInput.access_level=0": membershipNoAccessGrant,
"groupldap.AddInput.group_access=60": "doc/api/group_ldap_links.md#add-an-ldap-group-link lists 0 (No access) through 50 (Owner); " + membershipNoAdmin,
"groupsaml.AddInput.access_level=60": "doc/api/saml.md#add-a-saml-group-link lists 0 (No access) through 50 (Owner); " + membershipNoAdmin,
"groupldap.AddInput.group_access=0": groupLinkNoAccess,
"groupldap.AddInput.group_access=60": membershipNoAdmin,
"groupsaml.AddInput.access_level=0": groupLinkNoAccess,
"groupsaml.AddInput.access_level=60": membershipNoAdmin,

"memberroles.CreateInstanceInput.base_access_level=0": memberRoleBaseLevels,
"memberroles.CreateInstanceInput.base_access_level=5": memberRoleBaseLevels,
"memberroles.CreateInstanceInput.base_access_level=50": memberRoleBaseLevels,
"memberroles.CreateInstanceInput.base_access_level=60": memberRoleBaseLevels,

"broadcastmessages.CreateInput.target_access_levels=0": broadcastTargetLevels,
"broadcastmessages.CreateInput.target_access_levels=5": broadcastTargetLevels,
"broadcastmessages.CreateInput.target_access_levels=25": broadcastTargetLevels,
"broadcastmessages.CreateInput.target_access_levels=60": broadcastTargetLevels,
"broadcastmessages.UpdateInput.target_access_levels=0": broadcastTargetLevels,
"broadcastmessages.UpdateInput.target_access_levels=5": broadcastTargetLevels,
"broadcastmessages.UpdateInput.target_access_levels=25": broadcastTargetLevels,
"broadcastmessages.UpdateInput.target_access_levels=60": broadcastTargetLevels,

"groups.InvitedListInput.min_access_level=0": minAccessLevelFilter,
"groups.InvitedListInput.min_access_level=60": minAccessLevelFilter,
"groups.ListInput.min_access_level=0": minAccessLevelFilter,
"groups.ListInput.min_access_level=60": minAccessLevelFilter,
"groups.ListProjectsInput.min_access_level=0": minAccessLevelFilter,
"groups.ListProjectsInput.min_access_level=60": minAccessLevelFilter,
"groups.ListSharedProjectsInput.min_access_level=0": minAccessLevelFilter,
"groups.ListSharedProjectsInput.min_access_level=60": minAccessLevelFilter,
"groups.SharedWithListInput.min_access_level=0": minAccessLevelFilter,
"groups.SharedWithListInput.min_access_level=60": minAccessLevelFilter,
"groups.SubgroupsListInput.min_access_level=0": minAccessLevelFilter,
"projects.ListInvitedGroupsInput.min_access_level=0": minAccessLevelFilter,
"projects.ListProjectGroupsInput.shared_min_access_level=0": minAccessLevelFilter,
"groups.InvitedListInput.min_access_level=0": minAccessLevelFilter,
"groups.InvitedListInput.min_access_level=5": minAccessLevelFilter,
"groups.InvitedListInput.min_access_level=60": minAccessLevelFilter,
"groups.ListInput.min_access_level=0": minAccessLevelFilter,
"groups.ListInput.min_access_level=5": minAccessLevelFilter,
"groups.ListInput.min_access_level=60": minAccessLevelFilter,
"groups.ListProjectsInput.min_access_level=0": minAccessLevelFilter,
"groups.ListProjectsInput.min_access_level=5": minAccessLevelFilter,
"groups.ListProjectsInput.min_access_level=60": minAccessLevelFilter,
"groups.ListSharedProjectsInput.min_access_level=0": minAccessLevelFilter,
"groups.ListSharedProjectsInput.min_access_level=5": minAccessLevelFilter,
"groups.ListSharedProjectsInput.min_access_level=60": minAccessLevelFilter,
"groups.SharedWithListInput.min_access_level=0": minAccessLevelFilter,
"groups.SharedWithListInput.min_access_level=5": minAccessLevelFilter,
"groups.SharedWithListInput.min_access_level=60": minAccessLevelFilter,
"groups.SubgroupsListInput.min_access_level=0": minAccessLevelFilter,
"groups.SubgroupsListInput.min_access_level=5": minAccessLevelFilter,
"groups.SubgroupsListInput.min_access_level=60": minAccessLevelFilter,
"projects.ListInvitedGroupsInput.min_access_level=0": minAccessLevelFilter,
"projects.ListInvitedGroupsInput.min_access_level=5": minAccessLevelFilter,
"projects.ListInvitedGroupsInput.min_access_level=60": minAccessLevelFilter,
"projects.ListProjectGroupsInput.shared_min_access_level=0": minAccessLevelFilter,
"projects.ListProjectGroupsInput.shared_min_access_level=5": minAccessLevelFilter,
"projects.ListProjectGroupsInput.shared_min_access_level=60": minAccessLevelFilter,

"groups.ShareGroupInput.group_access=0": shareAccessLevel,
"groups.ShareGroupInput.group_access=60": shareAccessLevel,
"groupmembers.ShareInput.group_access=0": shareAccessLevel,
"groupmembers.ShareInput.group_access=60": shareAccessLevel,
"projects.ShareProjectInput.group_access=0": shareAccessLevel,
"projects.ShareProjectInput.group_access=5": shareAccessLevel,
"projects.ShareProjectInput.group_access=60": shareAccessLevel,

// --- Protection levels (AccessLevelValue) ---
Expand Down
15 changes: 9 additions & 6 deletions cmd/audit_meta_descriptions/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
// accepts;
// - an enum value the description spells for a parameter whose routes publish
// an enum that does not carry it;
// - a value the description spells for a parameter that publishes no enum but
// whose own schema description spells a value set of its own;
// - a value the description spells for a parameter that publishes no enum of
// strings but whose own schema description spells a value set of its own;
// - a "Parameter guidance:" line written for a parameter the action it names
// does not accept.
//
// The third rule exists because a schema enum is a closed set of strings, so a
// numeric value set cannot be one. gitlab_member_role's base_access_level
// offered 5 in the prose while the schema description said 10 to 50, and
// doc/api/member_roles.md agrees with the schema: GitLab refuses 5. Both sides
// The third rule exists because the audit folds only the string values of a
// published enum, so a numeric value set, published as an integer enum (as
// every access level parameter is since issue 1280) or as none, is compared
// with nothing through the schema. gitlab_member_role's base_access_level
// offered 5 in the prose while the schema description said 10 to 50, the set
// doc/api/member_roles.md lists, and GitLab refuses 5 (its model refuses 50
// as well, and the schema has said 10 to 40 since issue 1280). Both sides
// of that comparison are read by one function, so the description's own
// "10=Guest, 20=Reporter" list is the oracle and a set nothing spells is not
// judged at all.
Expand Down
Loading
Loading