Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
40f7609
feat(core)!: add Duo project settings and GitLab 16.0+ version policy
polaz Sep 23, 2026
d12880a
ci(release): publish a single GitHub release per version
polaz Sep 23, 2026
acd5591
fix(graphql): keep queries valid when every optional field is dropped
polaz Sep 23, 2026
0598c11
refactor(workitems): type the widget-to-parameter map exhaustively
polaz Sep 23, 2026
70c608a
test: cover schema probes, schema index and version fallbacks
polaz Sep 23, 2026
8dc1c76
fix(registry): enforce per-action version and tier requirements
polaz Sep 23, 2026
8d79fcf
fix(core): require GitLab 18.0 for project and group restore on Free
polaz Sep 23, 2026
2fb2566
fix(runners): validate the REST owned-runners response
polaz Sep 23, 2026
13998f3
fix(graphql): adapt pooled instance clients to the instance schema
polaz Sep 23, 2026
c14d25b
fix(users): report search failures and honour exclusion filters
polaz Sep 23, 2026
9cd2beb
fix(webhooks): gate webhook name/description and variable description
polaz Sep 23, 2026
9419793
fix(access-tokens): filter token state before paginating on older GitLab
polaz Sep 23, 2026
36d9d7b
test(core): restore Duo settings on the shared fixtures after each test
polaz Sep 23, 2026
9d64a47
fix(ci-tokens): keep reading the job token scope available on GitLab …
polaz Sep 23, 2026
3723f63
fix(runners): search owned runners before paginating on older GitLab
polaz Sep 23, 2026
13b6a6c
fix(users): filter before paginating and flag partial human filtering
polaz Sep 23, 2026
1e562cb
fix(workitems): refuse create widgets the instance cannot set at all
polaz Sep 23, 2026
9c8d33f
fix(core): validate REST responses on the commit diff and token fallb…
polaz Sep 23, 2026
f037951
fix(users): bound the pages scanned when emulating user filters
polaz Sep 23, 2026
fe0655b
fix(webhooks): refuse group webhooks on GitLab Free
polaz Sep 23, 2026
3204118
test(core): run Duo settings integration tests on disposable fixtures
polaz Sep 23, 2026
6e6f3ed
fix(core): gate the group project listing active filter at GitLab 18.8
polaz Sep 23, 2026
fbf7c8a
fix(webhooks): reject group-only event fields on project webhooks
polaz Sep 23, 2026
e61d263
fix(users): keep warnings of every smart search phase
polaz Sep 23, 2026
ca73396
test(core): validate Duo fixture create and update results
polaz Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}

# The packages are version-locked and the core release already carries both
# MCPB bundles, so the db GitHub release only duplicates it. Its tag must stay:
# release-please locates the previous db release by that tag (skip-github-release
# would not create one, leaving the next release without a base), so delete the
# release object alone and mark the core release as latest.
- name: Drop the duplicate db GitHub release
if: steps.release.outputs['packages/gitlab-mcp-db--tag_name'] != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
DB_TAG: ${{ steps.release.outputs['packages/gitlab-mcp-db--tag_name'] }}
CORE_TAG: ${{ steps.release.outputs['packages/gitlab-mcp--tag_name'] }}
run: |
gh release delete "$DB_TAG" --yes
if [ -n "$CORE_TAG" ]; then
gh release edit "$CORE_TAG" --latest
fi

# Without a root (".") component, release-please-action does not populate the
# aggregate `pr` output -- only per-package ones -- so the release PR must be
# resolved by its deterministic branch instead. The branch is empty when no
Expand Down Expand Up @@ -323,11 +341,11 @@ jobs:
# was extracted to the workspace root.
- name: Authenticate to MCP Registry (OIDC)
working-directory: packages/gitlab-mcp
run: $GITHUB_WORKSPACE/mcp-publisher login github-oidc
run: '"$GITHUB_WORKSPACE/mcp-publisher" login github-oidc'

- name: Publish to MCP Registry
working-directory: packages/gitlab-mcp
run: $GITHUB_WORKSPACE/mcp-publisher publish
run: '"$GITHUB_WORKSPACE/mcp-publisher" publish'

# Job 3: Sync generated metadata into the open release PR
# When release-please updates (not merges) the release PR, regenerate the
Expand Down Expand Up @@ -437,23 +455,25 @@ jobs:
steps:
- name: Summary
run: |
echo "## Release Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY

if [[ "${{ needs.release-please.outputs.release-created }}" == "true" ]]; then
echo "🚀 New version released: v${{ needs.release-please.outputs.release-version }}" >> $GITHUB_STEP_SUMMARY

if [[ "${{ needs.post-release.result }}" == "success" ]]; then
echo "✅ All publish steps completed" >> $GITHUB_STEP_SUMMARY
echo "📦 npm: @structured-world/gitlab-mcp@${{ needs.release-please.outputs.release-version }}" >> $GITHUB_STEP_SUMMARY
echo "🐳 Docker: ghcr.io/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.release-version }}" >> $GITHUB_STEP_SUMMARY
echo "📦 MCPB: gitlab-mcp-${{ needs.release-please.outputs.release-version }}.mcpb" >> $GITHUB_STEP_SUMMARY
echo "🌐 MCP Registry: io.github.structured-world/gitlab-mcp" >> $GITHUB_STEP_SUMMARY
{
echo "## Release Summary"
echo ""

if [[ "${{ needs.release-please.outputs.release-created }}" == "true" ]]; then
echo "🚀 New version released: v${{ needs.release-please.outputs.release-version }}"

if [[ "${{ needs.post-release.result }}" == "success" ]]; then
echo "✅ All publish steps completed"
echo "📦 npm: @structured-world/gitlab-mcp@${{ needs.release-please.outputs.release-version }}"
echo "🐳 Docker: ghcr.io/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.release-version }}"
echo "📦 MCPB: gitlab-mcp-${{ needs.release-please.outputs.release-version }}.mcpb"
echo "🌐 MCP Registry: io.github.structured-world/gitlab-mcp"
else
echo "❌ Post-release publish failed"
fi
elif [[ -n "${{ needs.release-please.outputs.pr-number }}" ]]; then
echo "📋 Release PR updated: #${{ needs.release-please.outputs.pr-number }}"
else
echo "❌ Post-release publish failed" >> $GITHUB_STEP_SUMMARY
echo "ℹ️ No release-worthy changes detected"
fi
elif [[ -n "${{ needs.release-please.outputs.pr-number }}" ]]; then
echo "📋 Release PR updated: #${{ needs.release-please.outputs.pr-number }}" >> $GITHUB_STEP_SUMMARY
else
echo "ℹ️ No release-worthy changes detected" >> $GITHUB_STEP_SUMMARY
fi
} >> "$GITHUB_STEP_SUMMARY"
54 changes: 54 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# AGENTS.md

Guidance for coding agents and reviewers working on this repository.

## GitLab version support

This server supports **GitLab 16.0 and later** (`MIN_SUPPORTED_VERSION` in
`packages/gitlab-mcp/src/services/InstanceCapabilities.ts`). It talks to GitLab only
through REST API v4 and GraphQL.

### Smart MCP, not an API proxy

Tools are an agent-facing layer over GitLab, not a 1:1 proxy: they compose calls,
translate parameters, filter and reshape results, and emulate behaviour an older or
lower-tier instance lacks. When a capability is missing on some instances, prefer, in
order: emulate or degrade (older equivalent, client-side filtering, another endpoint or
query, dropping a non-essential field), then report partial effect in the result, and
only then hide or refuse, when GitLab itself cannot provide it. Never gate away
behaviour a handler already emulates; read the handler before changing its gates.

### Rules

1. **Everything is available from 16.0 unless declared otherwise.** A tool, action or
parameter that needs a newer GitLab declares it in its `requirements`
(`default`, `actions.<action>` or `parameters.<name>`, each `{ tier, minVersion }`).
The registry hides whatever the connected instance does not meet.
2. **Declare `minVersion` only above the floor.** A value at or below 16.0 is ignored
by the gate and must not be written; it only misleads readers.
3. **Verify every version against GitLab's own sources, never from memory.** For each
new or changed endpoint, parameter, GraphQL field, argument or fragment type:
- REST: the `{{< history >}}` notes in `doc/api/*.md`, and the Grape
`optional`/`requires` declaration in `lib/api` or `ee/lib` at the release tag
(`vX.Y.0-ee`). Docs often omit the version of a single parameter; the source at
the tag is authoritative.
- GraphQL: `doc/api/graphql/reference` at the release tag. GitLab rejects the
**whole** query when any selected field, argument or inline-fragment type is
unknown, so a single new field gates the entire query and every action using it.
4. **Version-dependent behaviour in handlers** uses `instanceAtLeast` /
`currentInstance` from `packages/gitlab-mcp/src/entities/instance-version.ts` to
pick the native path or the emulation. `assertInstanceAtLeast` (a clear error) is
only for capabilities GitLab cannot provide on that instance, never a substitute
for an emulation that is possible.
5. **A 200 response is not proof a setting was applied.** GitLab ignores unknown REST
parameters and drops license-, add-on- or feature-flag-gated attributes without an
error. Where that matters, compare the returned entity with the request (see
`packages/gitlab-mcp/src/entities/core/duo-settings.ts`).

### For reviewers

For every added or changed `minVersion`, endpoint, REST parameter or GraphQL selection,
check the claimed version against the GitLab API documentation or source at that
release. Flag any GitLab API surface newer than 16.0 that is used without a gate or an
emulation, any `minVersion` at or below 16.0, and any gate that hides behaviour the
handler could emulate.
14 changes: 9 additions & 5 deletions packages/gitlab-mcp/docs/advanced/context-switching.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,15 @@ On instance switch:

### Version Compatibility

| GitLab Version | Work Items API | Iterations | OKRs |
|----------------|----------------|------------|------|
| 17.0+ | Full support | Full | Full |
| 16.x | Partial | Full | Limited |
| 15.x | Not available | Partial | Not available |
The server supports GitLab 16.0 and later. Where an older instance lacks newer API
surface, tools fall back to an equivalent it does have; only actions GitLab itself
cannot perform there are hidden (and refused if called), for example:

| GitLab Version | Work items |
|----------------|------------|
| 18.1+ | All actions, using namespace-level queries |
| 16.4 - 18.0 | All actions; listing goes through project or group queries, as does lookup by IID where the namespace query is missing |
| 16.0 - 16.3 | All actions except `add_link` / `remove_link` |

## Namespace Tier Cache

Expand Down
2 changes: 1 addition & 1 deletion packages/gitlab-mcp/docs/guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,6 @@ The server warns when a token expires within 7 days.

### Scope detection not working

The `/personal_access_tokens/self` endpoint requires GitLab 14.0+. On older versions, the server falls back to attempting operations directly.
Scope detection uses the `/personal_access_tokens/self` endpoint, available on every supported GitLab version (16.0+). If it fails, the server falls back to attempting operations directly.

For more connection issues, see [Troubleshooting](/troubleshooting/connection).
2 changes: 1 addition & 1 deletion packages/gitlab-mcp/docs/guide/authentication.md.in
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,6 @@ The server warns when a token expires within 7 days.

### Scope detection not working

The `/personal_access_tokens/self` endpoint requires GitLab 14.0+. On older versions, the server falls back to attempting operations directly.
Scope detection uses the `/personal_access_tokens/self` endpoint, available on every supported GitLab version (16.0+). If it fails, the server falls back to attempting operations directly.

For more connection issues, see [Troubleshooting](/troubleshooting/connection).
4 changes: 2 additions & 2 deletions packages/gitlab-mcp/docs/prompts/ci-cd/trigger-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ For pipelines using GitLab's typed inputs feature:
}
```

::: info GitLab 15.5+ Required
Pipeline inputs require GitLab 15.5 or later. Check your `.gitlab-ci.yml` for `spec.inputs` to see available inputs.
::: info GitLab 17.10+ Required
Passing pipeline inputs when creating a pipeline through the API requires GitLab 17.10 or later. Check your `.gitlab-ci.yml` for `spec.inputs` to see available inputs.
:::

## Trigger Manual Deploy Jobs
Expand Down
4 changes: 2 additions & 2 deletions packages/gitlab-mcp/docs/tools/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Trigger and control pipeline execution.

:::

### Pipeline Inputs (GitLab 15.5+)
### Pipeline Inputs (GitLab 17.10+)

For pipelines with typed inputs defined in `.gitlab-ci.yml`:

Expand Down Expand Up @@ -217,7 +217,7 @@ Trigger with inputs:

::: tip Variables vs Inputs
- **`variables`**: Legacy key-value pairs, no type validation
- **`inputs`**: Typed parameters with schema validation (requires GitLab 15.5+)
- **`inputs`**: Typed parameters with schema validation (requires GitLab 17.10+ for the pipeline creation API)

You can use both in the same request if needed.
:::
Expand Down
12 changes: 8 additions & 4 deletions packages/gitlab-mcp/src/cli/list-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
import * as fs from 'fs';
import * as path from 'path';
import { RegistryManager } from '../registry-manager';
import { getHighestTier, resolveRequirement } from '../services/InstanceCapabilities';
import {
effectiveMinVersion,
getHighestTier,
resolveRequirement,
} from '../services/InstanceCapabilities';
import { EnhancedToolDefinition, ToolRequirements } from '../types';
import { ProfileLoader, Preset, Profile } from '../profiles';

Expand Down Expand Up @@ -1747,10 +1751,10 @@ export async function main() {
const output = filteredTools.map((tool) => ({
name: tool.name,
description: tool.description,
// Mirror the documented ToolRequirement defaults (tier→free, minVersion→8.0)
// when requirements are declared; only an absent requirements block is 'unknown'.
// Mirror the documented ToolRequirement defaults (tier->free, minVersion->the
// supported floor) when requirements are declared; only an absent block is 'unknown'.
tier: tool.requirements ? (tool.requirements.default.tier ?? 'free') : 'unknown',
minVersion: tool.requirements ? (tool.requirements.default.minVersion ?? '8.0') : undefined,
minVersion: tool.requirements ? effectiveMinVersion(tool.requirements.default) : undefined,
parameters: tool.inputSchema,
}));
console.log(JSON.stringify(output, null, 2));
Expand Down
60 changes: 49 additions & 11 deletions packages/gitlab-mcp/src/entities/access_tokens/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,51 @@
import { ManageAccessTokenSchema } from './schema';
import { gitlab, toQuery } from '../../utils/gitlab-api';
import { ToolRegistry, EnhancedToolDefinition } from '../../types';
import { assertActionAllowed } from '../utils';
import { assertActionAllowed, GITLAB_MAX_PER_PAGE } from '../utils';
import { instanceAtLeast } from '../instance-version';

// Personal/project/group access tokens are Free tier. Project access tokens
// landed in 13.0, group access tokens in 14.7; the tool degrades per-action
// rather than gating the whole pair, so the lowest floor is declared here.
const FREE_REQ = { tier: 'free', minVersion: '13.0' } as const;
// Personal/project/group access tokens are Free tier; every endpoint used here
// predates the supported version floor.
const FREE_REQ = { tier: 'free' } as const;

/** A project/group token list page; `active` drives the client-side state filter. */
const ListedTokensSchema = z.array(z.looseObject({ active: z.boolean() }));

/**
* List project/group tokens, honouring `state`. The server-side filter landed in
* GitLab 17.2 (older instances ignore it and return every token), so there it is
* applied client-side on each token's `active` flag, before pagination: GitLab's
* pages are walked until the requested filtered page is complete or the list ends.
*/
async function listScopedTokens(
path: string,
query: { state?: 'active' | 'inactive'; per_page: number; page?: number },
) {
const { state, per_page: perPage, page = 1 } = query;
if (!state || instanceAtLeast('17.2')) {
return gitlab.get(path, { query: toQuery(query, []) });
}
const wanted = page * perPage;
const matches: Array<z.infer<typeof ListedTokensSchema>[number]> = [];
for (let serverPage = 1; matches.length < wanted; serverPage++) {

Check failure on line 32 in packages/gitlab-mcp/src/entities/access_tokens/registry.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This loop's stop condition tests "matches.length, matches, wanted" but the incrementer updates "serverPage".

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AaDPLKkgX4Nr1RJLY9D9&open=AaDPLKkgX4Nr1RJLY9D9&pullRequest=608
const parsed = ListedTokensSchema.safeParse(
await gitlab.get(path, {
query: toQuery({ per_page: GITLAB_MAX_PER_PAGE, page: serverPage }, []),
}),
);
if (!parsed.success) {
throw new Error(
`GitLab API error: unexpected access tokens response (${parsed.error.issues[0]?.message ?? 'invalid'})`,
);
}
const batch = parsed.data;
for (const token of batch) {
if (token.active === (state === 'active')) matches.push(token);
}
if (batch.length < GITLAB_MAX_PER_PAGE) break;
}
return matches.slice(wanted - perPage, wanted);
}

const NEW_TOKEN_NOTICE =
'This response contains a token value shown only once. Store it securely; it cannot be retrieved again.';
Expand Down Expand Up @@ -77,16 +116,15 @@

case 'list_project': {
const { action: _action, project_id, ...query } = input;
return gitlab.get(`projects/${encodeURIComponent(project_id)}/access_tokens`, {
query: toQuery(query, []),
});
return listScopedTokens(
`projects/${encodeURIComponent(project_id)}/access_tokens`,
query,
);
}

case 'list_group': {
const { action: _action, group_id, ...query } = input;
return gitlab.get(`groups/${encodeURIComponent(group_id)}/access_tokens`, {
query: toQuery(query, []),
});
return listScopedTokens(`groups/${encodeURIComponent(group_id)}/access_tokens`, query);
}

case 'get':
Expand Down
5 changes: 2 additions & 3 deletions packages/gitlab-mcp/src/entities/audit_events/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { ToolRegistry, EnhancedToolDefinition } from '../../types';
import { assertActionAllowed } from '../utils';

// Audit events are a Premium/Ultimate feature; the tier gate hides the tool on
// lower-tier instances. Instance audit events landed in 12.4, group/project in
// 12.5/13.1 - the lowest floor is declared here and the tier gate does the rest.
const PREMIUM_REQ = { tier: 'premium', minVersion: '12.4', notes: 'Audit Events' } as const;
// lower-tier instances. All endpoints predate the supported version floor.
const PREMIUM_REQ = { tier: 'premium', notes: 'Audit Events' } as const;

/**
* Resolve the REST collection path for a single audit event (get action).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export const containerRegistryToolRegistry: ToolRegistry = new Map<string, Enhan
description:
"Inspect the GitLab Container Registry. Actions: list_repositories (a project's image repositories), get_repository (single repository by ID), list_tags (tags of a repository), get_tag (single tag with manifest digest, size, and timestamps). Related: manage_registry to delete repositories and tags (including regex bulk cleanup).",
inputSchema: z.toJSONSchema(BrowseRegistrySchema),
requirements: { default: { tier: 'free', minVersion: '12.0' } },
// Newer detail fields are @optional in the documents, so every action works
// from the supported floor and returns what the instance has.
requirements: { default: { tier: 'free' } },
gate: { envVar: 'USE_REGISTRY', defaultValue: true },
handler: async (args: unknown): Promise<unknown> => {
const input = BrowseRegistrySchema.parse(args);
Expand Down Expand Up @@ -190,7 +192,7 @@ export const containerRegistryToolRegistry: ToolRegistry = new Map<string, Enhan
description:
'Delete GitLab Container Registry repositories and tags. Actions: delete_repository (remove a whole repository), delete_tag (remove one tag), delete_tags_bulk (regex cleanup with keep_n/older_than retention - destructive). Related: browse_registry to inspect before deleting.',
inputSchema: z.toJSONSchema(ManageRegistrySchema),
requirements: { default: { tier: 'free', minVersion: '12.0' } },
requirements: { default: { tier: 'free' } },
gate: { envVar: 'USE_REGISTRY', defaultValue: true },
handler: async (args: unknown): Promise<unknown> => {
const input = ManageRegistrySchema.parse(args);
Expand Down
Loading
Loading