From 40f7609e39f8bb40d2374560f6ccaa5b8374cf8b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 18:59:41 +0300 Subject: [PATCH 01/25] feat(core)!: add Duo project settings and GitLab 16.0+ version policy - manage_project update: auto_duo_code_review_enabled, duo_remote_flows_enabled, duo_sast_fp_detection_enabled, duo_sast_vr_workflow_enabled, duo_secret_detection_fp_enabled, duo_dependency_bump_breaking_changes_enabled; manage_namespace update: auto_duo_code_review_enabled. Each is gated by the GitLab version that introduced it - Project/group updates report settings GitLab silently ignored (license, add-on or feature flag) in not_applied, instead of implying success - Declare GitLab 16.0 as the oldest supported release; drop minVersion values at or below it and verify the remaining ones against GitLab release sources - Adapt GraphQL documents to the connected instance schema: fields marked @optional and fragments on unknown types are pruned, unused variables dropped - Emulate newer API behaviour on older instances instead of hiding it: client-side token state filter, user type filters, unified diff headers, REST fallback for owned runners, project/group fallbacks for work item list/get, deferred update for widgets the create input lacks - Add AGENTS.md with the version support policy for contributors and reviewers BREAKING CHANGE: GitLab releases older than 16.0 are no longer supported; tools are hidden on such instances. Closes #607 --- AGENTS.md | 54 +++ .../docs/advanced/context-switching.md | 14 +- .../gitlab-mcp/docs/guide/authentication.md | 2 +- .../docs/guide/authentication.md.in | 2 +- .../docs/prompts/ci-cd/trigger-deploy.md | 4 +- packages/gitlab-mcp/docs/tools/ci-cd.md | 4 +- packages/gitlab-mcp/src/cli/list-tools.ts | 12 +- .../src/entities/access_tokens/registry.ts | 35 +- .../src/entities/audit_events/registry.ts | 5 +- .../entities/container_registry/registry.ts | 6 +- .../src/entities/core/duo-settings.ts | 62 ++++ .../gitlab-mcp/src/entities/core/registry.ts | 126 ++++--- .../gitlab-mcp/src/entities/core/schema.ts | 34 +- .../src/entities/deploy-keys/registry.ts | 2 +- .../src/entities/environments/registry.ts | 4 +- .../gitlab-mcp/src/entities/files/registry.ts | 8 +- .../src/entities/instance-version.ts | 54 +++ .../src/entities/integrations/registry.ts | 4 +- .../src/entities/iterations/registry.ts | 2 +- .../src/entities/job-token-scope/registry.ts | 8 +- .../src/entities/labels/registry.ts | 4 +- .../src/entities/members/registry.ts | 18 +- .../src/entities/milestones/registry.ts | 6 +- .../gitlab-mcp/src/entities/mrs/registry.ts | 26 +- .../src/entities/pipelines/registry.ts | 9 +- .../src/entities/pipelines/schema.ts | 4 +- .../gitlab-mcp/src/entities/refs/registry.ts | 15 +- .../src/entities/releases/registry.ts | 4 +- .../src/entities/runners/registry.ts | 73 +++- .../src/entities/search/registry.ts | 4 +- .../src/entities/snippets/registry.ts | 4 +- .../src/entities/variables/registry.ts | 4 +- .../src/entities/vulnerabilities/registry.ts | 9 +- .../src/entities/webhooks/registry.ts | 21 +- .../gitlab-mcp/src/entities/wiki/registry.ts | 4 +- .../src/entities/workitems/registry.ts | 256 ++++++++------ packages/gitlab-mcp/src/graphql/client.ts | 22 +- .../src/graphql/containerRegistry.ts | 13 +- .../src/graphql/prepare-document.ts | 143 ++++++++ packages/gitlab-mcp/src/graphql/workItems.ts | 183 +++++++--- .../src/services/ConnectionManager.ts | 2 + .../src/services/InstanceCapabilities.ts | 42 +-- .../src/services/SchemaIntrospector.ts | 66 +++- .../src/services/WidgetAvailability.ts | 71 ++-- packages/gitlab-mcp/src/types.ts | 4 +- .../gitlab-mcp/src/utils/smart-user-search.ts | 64 ++-- .../gitlab-mcp/src/utils/workItemTypes.ts | 16 +- .../schemas-dependent/duo-settings.test.ts | 123 +++++++ .../tests/unit/cli/list-tools.test.ts | 6 +- .../entities/access_tokens/registry.test.ts | 60 +++- .../unit/entities/core/duo-settings.test.ts | 315 ++++++++++++++++++ .../tests/unit/entities/core/registry.test.ts | 106 +++++- .../entities/deploy-keys/registry.test.ts | 4 +- .../entities/environments/registry.test.ts | 4 +- .../entities/job-token-scope/registry.test.ts | 8 +- .../unit/entities/runners/registry.test.ts | 93 +++++- .../unit/entities/webhooks/registry.test.ts | 23 ++ .../workitems/schema-fallbacks.test.ts | 202 +++++++++++ .../unit/graphql/prepare-document.test.ts | 108 ++++++ .../ConnectionManagerEnhanced.test.ts | 2 + .../services/InstanceCapabilities.test.ts | 118 +++++-- .../unit/services/WidgetAvailability.test.ts | 12 +- .../unit/utils/smart-user-search.test.ts | 64 ++++ 63 files changed, 2315 insertions(+), 462 deletions(-) create mode 100644 AGENTS.md create mode 100644 packages/gitlab-mcp/src/entities/core/duo-settings.ts create mode 100644 packages/gitlab-mcp/src/entities/instance-version.ts create mode 100644 packages/gitlab-mcp/src/graphql/prepare-document.ts create mode 100644 packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/core/duo-settings.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..dee68cd57 --- /dev/null +++ b/AGENTS.md @@ -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.` or `parameters.`, 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. diff --git a/packages/gitlab-mcp/docs/advanced/context-switching.md b/packages/gitlab-mcp/docs/advanced/context-switching.md index 7fb1f398b..1b93b8b26 100644 --- a/packages/gitlab-mcp/docs/advanced/context-switching.md +++ b/packages/gitlab-mcp/docs/advanced/context-switching.md @@ -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. Tools and actions that rely on newer API +surface are hidden on older instances, for example: + +| GitLab Version | Work items | +|----------------|------------| +| 18.1+ | Full support, including namespace-level listing | +| 17.10 - 18.0 | Get, create, update, delete, links; no listing | +| 16.4 - 17.9 | Delete and link/unlink only | +| 16.0 - 16.3 | Delete only | ## Namespace Tier Cache diff --git a/packages/gitlab-mcp/docs/guide/authentication.md b/packages/gitlab-mcp/docs/guide/authentication.md index ba53d0956..edbb598bb 100644 --- a/packages/gitlab-mcp/docs/guide/authentication.md +++ b/packages/gitlab-mcp/docs/guide/authentication.md @@ -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). diff --git a/packages/gitlab-mcp/docs/guide/authentication.md.in b/packages/gitlab-mcp/docs/guide/authentication.md.in index 1f243e865..45543c416 100644 --- a/packages/gitlab-mcp/docs/guide/authentication.md.in +++ b/packages/gitlab-mcp/docs/guide/authentication.md.in @@ -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). diff --git a/packages/gitlab-mcp/docs/prompts/ci-cd/trigger-deploy.md b/packages/gitlab-mcp/docs/prompts/ci-cd/trigger-deploy.md index cf65c078a..a2a6cc3db 100644 --- a/packages/gitlab-mcp/docs/prompts/ci-cd/trigger-deploy.md +++ b/packages/gitlab-mcp/docs/prompts/ci-cd/trigger-deploy.md @@ -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 diff --git a/packages/gitlab-mcp/docs/tools/ci-cd.md b/packages/gitlab-mcp/docs/tools/ci-cd.md index 04c9cc498..0200bfd37 100644 --- a/packages/gitlab-mcp/docs/tools/ci-cd.md +++ b/packages/gitlab-mcp/docs/tools/ci-cd.md @@ -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`: @@ -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. ::: diff --git a/packages/gitlab-mcp/src/cli/list-tools.ts b/packages/gitlab-mcp/src/cli/list-tools.ts index f9966a024..372754638 100644 --- a/packages/gitlab-mcp/src/cli/list-tools.ts +++ b/packages/gitlab-mcp/src/cli/list-tools.ts @@ -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'; @@ -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)); diff --git a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts index 881abb05f..3d89605fb 100644 --- a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts +++ b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts @@ -4,11 +4,27 @@ import { ManageAccessTokenSchema } from './schema'; import { gitlab, toQuery } from '../../utils/gitlab-api'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } 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; + +/** + * 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. + */ +async function listScopedTokens(path: string, query: Record) { + const { state, ...rest } = query; + if (!state || instanceAtLeast('17.2')) { + return gitlab.get(path, { query: toQuery(query, []) }); + } + const tokens = await gitlab.get>(path, { + query: toQuery(rest, []), + }); + return tokens.filter((token) => token.active === (state === 'active')); +} const NEW_TOKEN_NOTICE = 'This response contains a token value shown only once. Store it securely; it cannot be retrieved again.'; @@ -77,16 +93,15 @@ export const accessTokensToolRegistry: ToolRegistry = new Map => { const input = BrowseRegistrySchema.parse(args); @@ -190,7 +192,7 @@ export const containerRegistryToolRegistry: ToolRegistry = new Map => { const input = ManageRegistrySchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/core/duo-settings.ts b/packages/gitlab-mcp/src/entities/core/duo-settings.ts new file mode 100644 index 000000000..252656360 --- /dev/null +++ b/packages/gitlab-mcp/src/entities/core/duo-settings.ts @@ -0,0 +1,62 @@ +/** + * GitLab Duo settings that GitLab drops from an update without an error when the + * add-on, licensed feature or feature flag behind them is missing + * (EE::API::Helpers::ProjectsHelpers#filter_attributes_using_license!, + * EE::Groups::UpdateService). An add-on purchase is invisible to tier gating, so + * the update handlers compare what was requested with the entity GitLab returns. + * Values are the condition GitLab checks, reported back when a setting is dropped. + */ +export const PROJECT_DUO_SETTINGS: Readonly> = { + auto_duo_code_review_enabled: + 'GitLab Duo turned on for the project and either the Duo Enterprise add-on or Duo Agent Platform code review', + duo_remote_flows_enabled: 'the Duo Agent Platform (ai_workflows) licensed feature', + duo_sast_fp_detection_enabled: 'the GitLab Duo AI features (Ultimate) licensed feature', + duo_sast_vr_workflow_enabled: 'the GitLab Duo AI features (Ultimate) licensed feature', + duo_secret_detection_fp_enabled: + 'the GitLab Duo AI features (Ultimate) licensed feature and the duo_secret_detection_false_positive feature flag', + duo_dependency_bump_breaking_changes_enabled: + 'the GitLab Duo AI features (Ultimate) licensed feature', +}; + +export const GROUP_DUO_SETTINGS: Readonly> = { + auto_duo_code_review_enabled: + 'GitLab Duo turned on for the group and either the Duo Enterprise add-on or Duo Agent Platform code review', +}; + +/** A requested setting that the returned entity does not reflect. */ +export interface UnappliedSetting { + setting: string; + requested: unknown; + /** Value GitLab returned; absent when GitLab does not expose the setting at all. */ + current?: unknown; + requires: string; +} + +/** + * Attach `not_applied` to an update response for every tracked setting whose + * requested value the returned entity does not carry. The response is returned + * unchanged when everything was applied or it is not a JSON object. + */ +export function withUnappliedSettings( + requested: Readonly>, + response: unknown, + tracked: Readonly>, +): unknown { + if (typeof response !== 'object' || response === null || Array.isArray(response)) { + return response; + } + const returned = response as Record; + + const notApplied: UnappliedSetting[] = []; + for (const [setting, requires] of Object.entries(tracked)) { + const value = requested[setting]; + if (value === undefined || returned[setting] === value) continue; + notApplied.push( + setting in returned + ? { setting, requested: value, current: returned[setting], requires } + : { setting, requested: value, requires }, + ); + } + + return notApplied.length === 0 ? response : { ...returned, not_applied: notApplied }; +} diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index a94a45988..56876d0e5 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -17,13 +17,12 @@ import { } from './schema'; import { enhancedFetch } from '../../utils/fetch'; import { normalizeProjectId } from '../../utils/projectIdentifier'; -import { smartUserSearch, type UserSearchParams } from '../../utils/smart-user-search'; +import { fetchUsers, smartUserSearch, type UserSearchParams } from '../../utils/smart-user-search'; import { cleanGidsFromObject } from '../../utils/idConversion'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } from '../utils'; -import { ConnectionManager } from '../../services/ConnectionManager'; -import { getGitLabApiUrlFromContext } from '../../oauth/token-context'; -import { parseVersion } from '../../utils/version'; +import { assertInstanceAtLeast, currentInstance, instanceAtLeast } from '../instance-version'; +import { GROUP_DUO_SETTINGS, PROJECT_DUO_SETTINGS, withUnappliedSettings } from './duo-settings'; /** * Minimal guard for the entity payload returned by the restore endpoints @@ -57,6 +56,25 @@ async function restoreEntity(apiUrl: string): Promise { return restored.data; } +interface CommitDiff { + diff: string; + old_path: string; + new_path: string; + new_file: boolean; + deleted_file: boolean; +} + +/** + * Prefix a commit diff with unified-diff file headers, as GitLab's own `unidiff` + * option does (Gitlab::Git::Diff#unidiff): empty and binary diffs stay as they are. + */ +function withUnifiedHeaders(d: CommitDiff): string { + if (!d.diff || d.diff.startsWith('Binary files')) return d.diff; + const oldHeader = d.new_file ? '/dev/null' : `a/${d.old_path}`; + const newHeader = d.deleted_file ? '/dev/null' : `b/${d.new_path}`; + return `--- ${oldHeader}\n+++ ${newHeader}\n${d.diff}`; +} + /** * Core tools registry - CQRS consolidated * All tools use discriminated union schema pattern. @@ -75,7 +93,7 @@ export const coreToolRegistry: ToolRegistry = new Map archived=false), which is server-side and therefore // pagination-safe. Projects pending deletion are already hidden from - // default listings, so this mapping matches `active` semantics. The - // instance version is detected at startup, so this is a cheap in-memory - // read; an unknown version fails open to the native parameter. - let activeFilterSupported = true; - try { - const version = ConnectionManager.getInstance().getInstanceInfo( - getGitLabApiUrlFromContext(), - ).version; - activeFilterSupported = - version === 'unknown' || parseVersion(version) >= parseVersion('18.5'); - } catch { - // Connection not initialised — assume supported (fail-open). - } + // default listings, so this mapping matches `active` semantics. + const activeFilterSupported = instanceAtLeast('18.5'); const applyActiveFilter = (value: boolean): void => { if (activeFilterSupported) { // active wins over an explicit archived filter: drop archived so the @@ -272,7 +279,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseNamespacesSchema.parse(args); @@ -356,7 +363,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseCommitsSchema.parse(args); @@ -422,9 +429,11 @@ export const coreToolRegistry: ToolRegistry = new Map ({ ...d, diff: withUnifiedHeaders(d) })); } /* istanbul ignore next -- unreachable with Zod discriminatedUnion */ @@ -453,7 +464,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseEventsSchema.parse(args); @@ -520,7 +531,8 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseUsersSchema.parse(args); @@ -547,22 +559,8 @@ export const coreToolRegistry: ToolRegistry = new Map { - if (value !== undefined && key !== 'smart_search' && key !== 'action') { - queryParams.set(key, String(value)); - } - }); - - const apiUrl = `${process.env.GITLAB_API_URL}/api/v4/users?${queryParams}`; - const response = await enhancedFetch(apiUrl); - - if (!response.ok) { - throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); - } - - const users = await response.json(); - return cleanGidsFromObject(users); + const { smart_search: _smart, action: _action, ...params } = input; + return cleanGidsFromObject(await fetchUsers(params)); } } @@ -595,7 +593,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseTodosSchema.parse(args); @@ -643,10 +641,10 @@ export const coreToolRegistry: ToolRegistry = new Map => { @@ -816,7 +820,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { @@ -974,7 +983,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = ManageTodosSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/core/schema.ts b/packages/gitlab-mcp/src/entities/core/schema.ts index 169072fc8..6dd381987 100644 --- a/packages/gitlab-mcp/src/entities/core/schema.ts +++ b/packages/gitlab-mcp/src/entities/core/schema.ts @@ -116,6 +116,30 @@ const UpdateProjectSchema = z.object({ .enum(['disabled', 'private', 'enabled']) .optional() .describe('Ultimate: requirements management access level.'), + // GitLab Duo settings. GitLab skips any the instance cannot honour (missing + // add-on or feature flag); those come back listed in `not_applied`. + auto_duo_code_review_enabled: flexibleBoolean + .optional() + .describe( + 'Premium+: run GitLab Duo code review automatically on every merge request. Needs a Duo Enterprise add-on or Duo Agent Platform code review.', + ), + duo_remote_flows_enabled: flexibleBoolean + .optional() + .describe('Premium+: allow GitLab Duo Agent Platform flows to run in this project.'), + duo_sast_fp_detection_enabled: flexibleBoolean + .optional() + .describe('Ultimate: let GitLab Duo flag likely false positives in SAST findings.'), + duo_sast_vr_workflow_enabled: flexibleBoolean + .optional() + .describe('Ultimate: let GitLab Duo run the SAST vulnerability resolution workflow.'), + duo_secret_detection_fp_enabled: flexibleBoolean + .optional() + .describe('Ultimate: let GitLab Duo flag likely false positives in secret detection findings.'), + duo_dependency_bump_breaking_changes_enabled: flexibleBoolean + .optional() + .describe( + 'Ultimate: let GitLab Duo resolve breaking changes introduced by dependency version bumps.', + ), }); // --- Action: delete --- @@ -237,6 +261,12 @@ const UpdateNamespaceSchema = z.object({ .number() .optional() .describe('Ultimate: max unique project downloads per user before action is taken.'), + auto_duo_code_review_enabled: z + .boolean() + .optional() + .describe( + 'Premium+: run GitLab Duo code review automatically on merge requests in this group, cascading to its subgroups and projects. Needs a Duo Enterprise add-on or Duo Agent Platform code review; reported in `not_applied` when GitLab skips it.', + ), }); // --- Action: delete --- @@ -250,8 +280,8 @@ const RestoreNamespaceSchema = z.object({ action: z .literal('restore') .describe( - 'Restore a soft-deleted group within its deletion cooldown window. Requires GitLab 18.0+ ' + - '(group restore GA in 18.9) and group Owner or instance Administrator.', + 'Restore a soft-deleted group within its deletion cooldown window. On GitLab Free needs ' + + '17.11+. Requires group Owner or instance Administrator.', ), group_id: requiredId.describe('Group ID or URL-encoded path of the group to restore.'), }); diff --git a/packages/gitlab-mcp/src/entities/deploy-keys/registry.ts b/packages/gitlab-mcp/src/entities/deploy-keys/registry.ts index cedc2c6b5..17546a9f0 100644 --- a/packages/gitlab-mcp/src/entities/deploy-keys/registry.ts +++ b/packages/gitlab-mcp/src/entities/deploy-keys/registry.ts @@ -6,7 +6,7 @@ import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } from '../utils'; // Deploy keys have existed since early GitLab and are Free tier throughout. -const FREE_REQ = { tier: 'free', minVersion: '8.0' } as const; +const FREE_REQ = { tier: 'free' } as const; /** * Deploy keys tools registry - 2 CQRS tools. diff --git a/packages/gitlab-mcp/src/entities/environments/registry.ts b/packages/gitlab-mcp/src/entities/environments/registry.ts index c32ff4746..18a5eda88 100644 --- a/packages/gitlab-mcp/src/entities/environments/registry.ts +++ b/packages/gitlab-mcp/src/entities/environments/registry.ts @@ -26,7 +26,7 @@ export const environmentsToolRegistry: ToolRegistry = new Map => { const input = BrowseEnvironmentsSchema.parse(args); @@ -72,7 +72,7 @@ export const environmentsToolRegistry: ToolRegistry = new Map => { const input = ManageEnvironmentSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/files/registry.ts b/packages/gitlab-mcp/src/entities/files/registry.ts index 4c26dbf34..5b1f8e888 100644 --- a/packages/gitlab-mcp/src/entities/files/registry.ts +++ b/packages/gitlab-mcp/src/entities/files/registry.ts @@ -26,7 +26,11 @@ export const filesToolRegistry: ToolRegistry = new Map { const input = BrowseFilesSchema.parse(args); @@ -106,7 +110,7 @@ export const filesToolRegistry: ToolRegistry = new Map { const input = ManageFilesSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/instance-version.ts b/packages/gitlab-mcp/src/entities/instance-version.ts new file mode 100644 index 000000000..69e38767d --- /dev/null +++ b/packages/gitlab-mcp/src/entities/instance-version.ts @@ -0,0 +1,54 @@ +import { ConnectionManager } from '../services/ConnectionManager'; +import type { GitLabTier } from '../services/GitLabVersionDetector'; +import { getGitLabApiUrlFromContext } from '../oauth/token-context'; +import { parseVersion } from '../utils/version'; + +/** + * Whether the GitLab instance serving the current request is at least `minVersion`. + * Used where a version gate cannot live in tool requirements, e.g. a parameter + * whose availability differs per action. The version is detected at startup, so + * this is an in-memory read; an unknown version fails open. + */ +export function instanceAtLeast(minVersion: string): boolean { + const version = currentInstance()?.version ?? 'unknown'; + return version === 'unknown' || parseVersion(version) >= parseVersion(minVersion); +} + +/** Detected version/tier of the instance serving the current request, if known. */ +export function currentInstance(): { version: string; tier: GitLabTier } | undefined { + try { + return ConnectionManager.getInstance().getInstanceInfo(getGitLabApiUrlFromContext()); + } catch { + // Connection not initialised: nothing is known yet. + return undefined; + } +} + +/** + * Whether the GraphQL schema of the instance serving the current request has the + * type, field and argument. Lets a handler pick its native query or a fallback + * from what the instance actually exposes (version, edition and feature flags + * alike). True when the schema is not known yet (fail-open to the native path). + */ +export function graphqlSupports(typeName: string, fieldName?: string, argName?: string): boolean { + let index; + try { + index = ConnectionManager.getInstance().getSchemaInfo(getGitLabApiUrlFromContext()).fieldIndex; + } catch { + return true; + } + if (!index) return true; + const fields = index.get(typeName); + if (!fields) return false; + if (!fieldName) return true; + const field = fields.get(fieldName); + if (!field) return false; + return !argName || field.args.has(argName); +} + +/** Throw a clear error when the instance is older than `minVersion` for `feature`. */ +export function assertInstanceAtLeast(minVersion: string, feature: string): void { + if (!instanceAtLeast(minVersion)) { + throw new Error(`${feature} requires GitLab ${minVersion}+`); + } +} diff --git a/packages/gitlab-mcp/src/entities/integrations/registry.ts b/packages/gitlab-mcp/src/entities/integrations/registry.ts index df3925705..8f0f41515 100644 --- a/packages/gitlab-mcp/src/entities/integrations/registry.ts +++ b/packages/gitlab-mcp/src/entities/integrations/registry.ts @@ -25,7 +25,7 @@ export const integrationsToolRegistry: ToolRegistry = new Map { const input = BrowseIntegrationsSchema.parse(args); @@ -73,7 +73,7 @@ export const integrationsToolRegistry: ToolRegistry = new Map { const input = ManageIntegrationSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/iterations/registry.ts b/packages/gitlab-mcp/src/entities/iterations/registry.ts index 8418c80c5..ddbe26eda 100644 --- a/packages/gitlab-mcp/src/entities/iterations/registry.ts +++ b/packages/gitlab-mcp/src/entities/iterations/registry.ts @@ -19,7 +19,7 @@ export const iterationsToolRegistry: ToolRegistry = new Map { const input = BrowseIterationsSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts b/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts index dc14b72ba..8388704b7 100644 --- a/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts +++ b/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts @@ -27,10 +27,10 @@ function scopeBase(projectId: number): string { return `projects/${projectId}/job_token_scope`; } -// Free tier throughout; the inbound project allowlist lands in GitLab 15.9 and -// the group allowlist in 16.0. -const SCOPE_REQ = { tier: 'free', minVersion: '15.9' } as const; -const GROUP_REQ = { tier: 'free', minVersion: '16.0' } as const; +// Free tier throughout. The job token scope API (project allowlist, enforcement +// toggle) landed in GitLab 16.1, the group allowlist in 16.10. +const SCOPE_REQ = { tier: 'free', minVersion: '16.1' } as const; +const GROUP_REQ = { tier: 'free', minVersion: '16.10' } as const; /** * CI/CD job token scope tools registry - 2 CQRS tools. diff --git a/packages/gitlab-mcp/src/entities/labels/registry.ts b/packages/gitlab-mcp/src/entities/labels/registry.ts index 3d8eea688..124363b7c 100644 --- a/packages/gitlab-mcp/src/entities/labels/registry.ts +++ b/packages/gitlab-mcp/src/entities/labels/registry.ts @@ -24,7 +24,7 @@ export const labelsToolRegistry: ToolRegistry = new Map { const input = BrowseLabelsSchema.parse(args); @@ -73,7 +73,7 @@ export const labelsToolRegistry: ToolRegistry = new Map { const input = ManageLabelSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/members/registry.ts b/packages/gitlab-mcp/src/entities/members/registry.ts index 37d7f421a..c076f3053 100644 --- a/packages/gitlab-mcp/src/entities/members/registry.ts +++ b/packages/gitlab-mcp/src/entities/members/registry.ts @@ -25,14 +25,10 @@ export const membersToolRegistry: ToolRegistry = new Map => { @@ -112,13 +108,9 @@ export const membersToolRegistry: ToolRegistry = new Map => { diff --git a/packages/gitlab-mcp/src/entities/milestones/registry.ts b/packages/gitlab-mcp/src/entities/milestones/registry.ts index 168d188a1..3f64a976f 100644 --- a/packages/gitlab-mcp/src/entities/milestones/registry.ts +++ b/packages/gitlab-mcp/src/entities/milestones/registry.ts @@ -26,9 +26,9 @@ export const milestonesToolRegistry: ToolRegistry = new Map { const input = ManageMilestoneSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/mrs/registry.ts b/packages/gitlab-mcp/src/entities/mrs/registry.ts index 1047452f3..32235de27 100644 --- a/packages/gitlab-mcp/src/entities/mrs/registry.ts +++ b/packages/gitlab-mcp/src/entities/mrs/registry.ts @@ -182,9 +182,9 @@ export const mrsToolRegistry: ToolRegistry = new Map { const input = BrowseMrDiscussionsSchema.parse(args); @@ -406,11 +406,11 @@ export const mrsToolRegistry: ToolRegistry = new Map { const input = ManageDraftNotesSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/pipelines/registry.ts b/packages/gitlab-mcp/src/entities/pipelines/registry.ts index d58373e0c..74b3538d7 100644 --- a/packages/gitlab-mcp/src/entities/pipelines/registry.ts +++ b/packages/gitlab-mcp/src/entities/pipelines/registry.ts @@ -26,7 +26,7 @@ export const pipelinesToolRegistry: ToolRegistry = new Map => { const input = BrowsePipelinesSchema.parse(args); @@ -191,7 +191,10 @@ export const pipelinesToolRegistry: ToolRegistry = new Map => { const input = ManagePipelineSchema.parse(args); @@ -214,7 +217,7 @@ export const pipelinesToolRegistry: ToolRegistry = new Map 0) { body.inputs = inputs; } diff --git a/packages/gitlab-mcp/src/entities/pipelines/schema.ts b/packages/gitlab-mcp/src/entities/pipelines/schema.ts index ebcdffb9d..4302d9e99 100644 --- a/packages/gitlab-mcp/src/entities/pipelines/schema.ts +++ b/packages/gitlab-mcp/src/entities/pipelines/schema.ts @@ -14,7 +14,7 @@ const PipelineVariableSchema = z.object({ .describe('Variable type: env_var (default) or file'), }); -// Pipeline input value types (GitLab 15.5+ supports string, number, boolean, array) +// Pipeline input value types: string, number, boolean, array const PipelineInputValueSchema = z .union([z.string(), z.number(), z.boolean(), z.array(z.string())]) .describe('Input value: string, number, boolean, or array of strings'); @@ -46,7 +46,7 @@ const CreatePipelineSchema = z.object({ .record(z.string(), PipelineInputValueSchema) .optional() .describe( - 'Typed pipeline inputs defined in .gitlab-ci.yml spec (GitLab 15.5+). Keys must match input names in pipeline spec.', + 'Typed pipeline inputs defined in the .gitlab-ci.yml spec (GitLab 17.10+). Keys must match input names in the pipeline spec.', ), }); diff --git a/packages/gitlab-mcp/src/entities/refs/registry.ts b/packages/gitlab-mcp/src/entities/refs/registry.ts index f803b18cd..35f9fb065 100644 --- a/packages/gitlab-mcp/src/entities/refs/registry.ts +++ b/packages/gitlab-mcp/src/entities/refs/registry.ts @@ -26,11 +26,9 @@ export const refsToolRegistry: ToolRegistry = new Map => { @@ -107,17 +105,14 @@ export const refsToolRegistry: ToolRegistry = new Map => { diff --git a/packages/gitlab-mcp/src/entities/releases/registry.ts b/packages/gitlab-mcp/src/entities/releases/registry.ts index 37cf9bf0e..941c90024 100644 --- a/packages/gitlab-mcp/src/entities/releases/registry.ts +++ b/packages/gitlab-mcp/src/entities/releases/registry.ts @@ -23,7 +23,7 @@ export const releasesToolRegistry: ToolRegistry = new Map => { const input = BrowseReleasesSchema.parse(args); @@ -78,7 +78,7 @@ export const releasesToolRegistry: ToolRegistry = new Map => { const input = ManageReleaseSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/runners/registry.ts b/packages/gitlab-mcp/src/entities/runners/registry.ts index 3e65d06a2..6035afa0a 100644 --- a/packages/gitlab-mcp/src/entities/runners/registry.ts +++ b/packages/gitlab-mcp/src/entities/runners/registry.ts @@ -6,7 +6,8 @@ import { assertActionAllowed } from '../utils'; import { ConnectionManager } from '../../services/ConnectionManager'; import { cleanGidsFromObject } from '../../utils/idConversion'; import { getGitLabApiUrlFromContext } from '../../oauth/token-context'; -import { gitlab } from '../../utils/gitlab-api'; +import { gitlab, toQuery } from '../../utils/gitlab-api'; +import { graphqlSupports } from '../instance-version'; import { LIST_RUNNERS, LIST_OWNED_RUNNERS, @@ -93,6 +94,69 @@ function applyRunnerSettings( if (src.maintenance_note !== undefined) target.maintenanceNote = src.maintenance_note; } +interface RestRunner { + id: number; + description: string | null; + runner_type: string; + status: string | null; + paused: boolean; +} + +/** + * The current user's runners through REST, shaped like the GraphQL connection, + * for instances without currentUser.runners (GitLab 18.3). REST pages by number, + * so the cursor is the next page number; `search` is matched client-side on the + * description. Fields the REST listing lacks are null. + */ +async function listOwnedRunnersViaRest(input: { + type?: string; + status?: string; + paused?: boolean; + tag_list?: string[]; + search?: string; + first?: number; + after?: string; +}) { + const perPage = input.first ?? 20; + const page = Number(input.after) > 0 ? Number(input.after) : 1; + const runners = await gitlab.get('runners', { + query: toQuery({ + type: input.type?.toLowerCase(), + status: input.status?.toLowerCase(), + paused: input.paused, + tag_list: input.tag_list?.join(','), + per_page: perPage, + page, + }), + }); + const search = input.search?.toLowerCase(); + const matching = search + ? runners.filter((r) => (r.description ?? '').toLowerCase().includes(search)) + : runners; + return { + nodes: matching.map((r) => ({ + id: r.id, + description: r.description, + runnerType: r.runner_type.toUpperCase(), + status: r.status?.toUpperCase() ?? null, + paused: r.paused, + locked: null, + runUntagged: null, + tagList: null, + accessLevel: null, + maximumTimeout: null, + jobExecutionStatus: null, + jobCount: null, + contactedAt: null, + createdAt: null, + })), + pageInfo: { + hasNextPage: runners.length === perPage, + endCursor: runners.length === perPage ? String(page + 1) : null, + }, + }; +} + /** Throw on a non-empty GraphQL mutation `errors` array. */ function assertNoErrors(errors: string[] | undefined): void { if (errors && errors.length > 0) { @@ -111,7 +175,7 @@ export const runnersToolRegistry: ToolRegistry = new Map => { const input = BrowseRunnersSchema.parse(args); @@ -127,6 +191,9 @@ export const runnersToolRegistry: ToolRegistry = new Map => { const input = ManageRunnerSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/search/registry.ts b/packages/gitlab-mcp/src/entities/search/registry.ts index 91ffd16d8..6fdbec250 100644 --- a/packages/gitlab-mcp/src/entities/search/registry.ts +++ b/packages/gitlab-mcp/src/entities/search/registry.ts @@ -24,9 +24,9 @@ export const searchToolRegistry: ToolRegistry = new Map => { const input = BrowseSnippetsSchema.parse(args); @@ -95,7 +95,7 @@ export const snippetsToolRegistry: ToolRegistry = new Map => { const input = ManageSnippetSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/variables/registry.ts b/packages/gitlab-mcp/src/entities/variables/registry.ts index fcda8a4b2..e0e5742ab 100644 --- a/packages/gitlab-mcp/src/entities/variables/registry.ts +++ b/packages/gitlab-mcp/src/entities/variables/registry.ts @@ -24,7 +24,7 @@ export const variablesToolRegistry: ToolRegistry = new Map { const input = BrowseVariablesSchema.parse(args); @@ -74,7 +74,7 @@ export const variablesToolRegistry: ToolRegistry = new Map { const input = ManageVariableSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/vulnerabilities/registry.ts b/packages/gitlab-mcp/src/entities/vulnerabilities/registry.ts index 28fd09160..4f7b70673 100644 --- a/packages/gitlab-mcp/src/entities/vulnerabilities/registry.ts +++ b/packages/gitlab-mcp/src/entities/vulnerabilities/registry.ts @@ -19,13 +19,8 @@ import { } from '../../graphql/vulnerabilities'; // Vulnerability Management is Ultimate-tier; the capability gate hides the tool on -// lower tiers. The GraphQL surface stabilised around 13.x; the floor is declared -// here and the tier gate does the rest. -const ULTIMATE_REQ = { - tier: 'ultimate', - minVersion: '13.0', - notes: 'Vulnerability Management', -} as const; +// lower tiers. +const ULTIMATE_REQ = { tier: 'ultimate', notes: 'Vulnerability Management' } as const; const vulnerabilityGid = (id: number): string => `gid://gitlab/Vulnerability/${id}`; diff --git a/packages/gitlab-mcp/src/entities/webhooks/registry.ts b/packages/gitlab-mcp/src/entities/webhooks/registry.ts index 32c6a72c9..ef62e739c 100644 --- a/packages/gitlab-mcp/src/entities/webhooks/registry.ts +++ b/packages/gitlab-mcp/src/entities/webhooks/registry.ts @@ -4,6 +4,7 @@ import { ManageWebhookSchema } from './schema'; import { gitlab, toQuery } from '../../utils/gitlab-api'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } from '../utils'; +import { assertInstanceAtLeast } from '../instance-version'; /** * Webhooks tools registry - 2 CQRS tools (discriminated union schema) @@ -23,7 +24,7 @@ export const webhooksToolRegistry: ToolRegistry = new Map { const input = BrowseWebhooksSchema.parse(args); @@ -82,11 +83,20 @@ export const webhooksToolRegistry: ToolRegistry = new Map { const input = BrowseWikiSchema.parse(args); @@ -68,7 +68,7 @@ export const wikiToolRegistry: ToolRegistry = new Map { const input = ManageWikiSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/workitems/registry.ts b/packages/gitlab-mcp/src/entities/workitems/registry.ts index d830b1407..e50e19e5b 100644 --- a/packages/gitlab-mcp/src/entities/workitems/registry.ts +++ b/packages/gitlab-mcp/src/entities/workitems/registry.ts @@ -12,6 +12,8 @@ import { type GitLabWorkItem, } from '../../utils/idConversion'; import { WidgetAvailability } from '../../services/WidgetAvailability'; +import { graphqlSupports } from '../instance-version'; +import type { GraphQLClient } from '../../graphql/client'; import { getGitLabApiUrlFromContext } from '../../oauth/token-context'; import { createVersionRestrictedError, @@ -25,12 +27,93 @@ interface WorkItemType { name: string; } +/** Create-input widgets whose update-input shape is identical, so they can be deferred as-is. */ +const SAME_SHAPE_CREATE_WIDGETS = [ + 'assigneesWidget', + 'milestoneWidget', + 'startAndDueDateWidget', + 'hierarchyWidget', + 'weightWidget', + 'iterationWidget', + 'healthStatusWidget', + 'progressWidget', + 'colorWidget', +] as const; + +/** Tool parameter a deferred widget carries, named in failure reports. */ +const DEFERRED_WIDGET_PROPERTY: Readonly> = { + timeTrackingWidget: 'timeEstimate', + descriptionWidget: 'description', + labelsWidget: 'labelIds', + assigneesWidget: 'assigneeIds', + milestoneWidget: 'milestoneId', + startAndDueDateWidget: 'dates', + hierarchyWidget: 'hierarchy', + weightWidget: 'weight', + iterationWidget: 'iterationId', + healthStatusWidget: 'healthStatus', + progressWidget: 'progressCurrentValue', + colorWidget: 'color', + verificationStatusWidget: 'verificationStatus', +}; + +/** A single-field widget input reports its value; a multi-field one reports the object. */ +const unwrapWidget = (value: unknown): unknown => + value !== null && typeof value === 'object' && Object.keys(value).length === 1 + ? Object.values(value)[0] + : value; + +/** Whether this instance's workItemCreate input accepts the field. */ +const createSupports = (field: string): boolean => graphqlSupports('WorkItemCreateInput', field); + +/** + * List a namespace's work items: the namespace-level query when the instance has + * it, otherwise the project listing, then the group one. + */ +async function listWorkItems( + client: GraphQLClient, + vars: { namespacePath: string; types?: string[]; first: number; after?: string }, +) { + if (graphqlSupports('Namespace', 'workItems')) { + return (await client.request(GET_NAMESPACE_WORK_ITEMS, vars)).namespace?.workItems ?? null; + } + const { project } = await client.request(LIST_PROJECT_WORK_ITEMS, vars); + if (project) return project.workItems; + if (!graphqlSupports('Group', 'workItems')) { + throw new Error( + `"${vars.namespacePath}" is not a project, and this GitLab instance cannot list group-level work items`, + ); + } + return (await client.request(LIST_GROUP_WORK_ITEMS, vars)).group?.workItems ?? null; +} + +/** Find a work item by namespace path + IID, falling back like listWorkItems. */ +async function getWorkItemByIid( + client: GraphQLClient, + namespacePath: string, + iid: string, +): Promise { + const vars = { namespacePath, iid }; + if (graphqlSupports('Namespace', 'workItem')) { + return (await client.request(GET_WORK_ITEM_BY_IID, vars)).namespace?.workItem ?? null; + } + const { project } = await client.request(GET_PROJECT_WORK_ITEM_BY_IID, vars); + if (project) return project.workItems?.nodes[0] ?? null; + if (!graphqlSupports('Group', 'workItems')) return null; + const { group } = await client.request(GET_GROUP_WORK_ITEM_BY_IID, vars); + return group?.workItems?.nodes[0] ?? null; +} + import { CREATE_WORK_ITEM_WITH_WIDGETS, WorkItemCreateInput, GET_NAMESPACE_WORK_ITEMS, + LIST_PROJECT_WORK_ITEMS, + LIST_GROUP_WORK_ITEMS, GET_WORK_ITEM, GET_WORK_ITEM_BY_IID, + GET_PROJECT_WORK_ITEM_BY_IID, + GET_GROUP_WORK_ITEM_BY_IID, UPDATE_WORK_ITEM, DELETE_WORK_ITEM, TIMELOG_DELETE, @@ -280,7 +363,9 @@ export const workitemsToolRegistry: ToolRegistry = new Map => { const input = BrowseWorkItemsSchema.parse(args); @@ -300,16 +385,8 @@ export const workitemsToolRegistry: ToolRegistry = new Map = {}; + if (createInput.description !== undefined && !createSupports('description')) { + deferred.descriptionWidget = { description: createInput.description }; + delete createInput.description; + } + if (createInput.labelsWidget && !createSupports('labelsWidget')) { + deferred.labelsWidget = { addLabelIds: createInput.labelsWidget.labelIds }; + delete createInput.labelsWidget; + } + for (const key of SAME_SHAPE_CREATE_WIDGETS) { + if (createInput[key] !== undefined && !createSupports(key)) { + Object.assign(deferred, { [key]: createInput[key] }); + delete createInput[key]; + } + } + if (timeEstimate !== undefined) { + deferred.timeTrackingWidget = { timeEstimate }; + } + // Use comprehensive mutation with widgets support const response = await client.request(CREATE_WORK_ITEM_WITH_WIDGETS, { input: createInput, @@ -593,88 +693,43 @@ export const workitemsToolRegistry: ToolRegistry = new Map 0) { + const withWarning = (error: string) => ({ + ...cleanWorkItemResponse(createdWorkItem as unknown as GitLabWorkItem), + _warning: { + message: + 'Work item created successfully, but some properties could not be applied', + failedProperties: Object.fromEntries( + Object.entries(deferred).map(([widget, requestedValue]) => [ + DEFERRED_WIDGET_PROPERTY[widget] ?? widget, + { requestedValue: unwrapWidget(requestedValue), error }, + ]), + ), + }, + }); + try { const updateResponse = await client.request(UPDATE_WORK_ITEM, { - input: updateInput, + input: { id: createdWorkItem.id, ...deferred }, }); - if ( - updateResponse.workItemUpdate?.errors?.length && - updateResponse.workItemUpdate.errors.length > 0 - ) { - // Update failed - return create result with warning - const cleanedResult = cleanWorkItemResponse( - createdWorkItem as unknown as GitLabWorkItem, - ); - return { - ...cleanedResult, - _warning: { - message: - 'Work item created successfully, but some properties could not be applied', - failedProperties: { - timeEstimate: { - requestedValue: timeEstimate, - error: updateResponse.workItemUpdate.errors.join(', '), - }, - }, - }, - }; + if (updateResponse.workItemUpdate?.errors?.length) { + return withWarning(updateResponse.workItemUpdate.errors.join(', ')); } - if (updateResponse.workItemUpdate?.workItem) { - // Return updated work item with time estimate applied return cleanWorkItemResponse( updateResponse.workItemUpdate.workItem as unknown as GitLabWorkItem, ); } - - // Update returned no work item but also no errors - return create result with warning - const cleanedResult = cleanWorkItemResponse( - createdWorkItem as unknown as GitLabWorkItem, - ); - return { - ...cleanedResult, - _warning: { - message: - 'Work item created successfully, but some properties could not be applied', - failedProperties: { - timeEstimate: { - requestedValue: timeEstimate, - error: 'Time estimate update returned no work item', - }, - }, - }, - }; + return withWarning('Follow-up update returned no work item'); } catch (updateError) { - // Update failed with exception - return create result with warning - const cleanedResult = cleanWorkItemResponse( - createdWorkItem as unknown as GitLabWorkItem, + return withWarning( + updateError instanceof Error + ? updateError.message + : 'Unknown error applying deferred properties', ); - return { - ...cleanedResult, - _warning: { - message: - 'Work item created successfully, but some properties could not be applied', - failedProperties: { - timeEstimate: { - requestedValue: timeEstimate, - error: - updateError instanceof Error - ? updateError.message - : 'Unknown error applying time estimate', - }, - }, - }, - }; } } @@ -939,6 +994,19 @@ export const workitemsToolRegistry: ToolRegistry = new Map key.endsWith('Widget') && !graphqlSupports('WorkItemUpdateInput', key), + ); + if (unsupported.length > 0) { + throw new Error( + `This GitLab instance cannot update ${unsupported + .map((key) => DEFERRED_WIDGET_PROPERTY[key] ?? key) + .join(', ')} on work items`, + ); + } + // Use single GraphQL mutation with dynamic input const response = await client.request(UPDATE_WORK_ITEM, { input: updateInput }); diff --git a/packages/gitlab-mcp/src/graphql/client.ts b/packages/gitlab-mcp/src/graphql/client.ts index c7e3d12c2..6b914deb5 100644 --- a/packages/gitlab-mcp/src/graphql/client.ts +++ b/packages/gitlab-mcp/src/graphql/client.ts @@ -2,6 +2,8 @@ import { ExecutionResult, print } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { DEFAULT_HEADERS } from '../http-client'; import { enhancedFetch } from '../utils/fetch'; +import type { SchemaFieldIndex } from '../services/SchemaIntrospector'; +import { prepareDocument } from './prepare-document'; export interface GraphQLClientOptions { endpoint: string; @@ -11,12 +13,22 @@ export interface GraphQLClientOptions { export class GraphQLClient { private _endpoint: string; private defaultHeaders: Record; + private schemaIndexProvider: () => SchemaFieldIndex | undefined = () => undefined; constructor(endpoint: string, options?: { headers?: Record }) { this._endpoint = endpoint; this.defaultHeaders = options?.headers ?? {}; } + /** + * Source of the instance schema used to adapt each document before sending it + * (see prepareDocument). Read per request, so it sees introspection results + * that arrive after the client was created. + */ + public setSchemaIndexProvider(provider: () => SchemaFieldIndex | undefined): void { + this.schemaIndexProvider = provider; + } + public get endpoint(): string { return this._endpoint; } @@ -38,7 +50,13 @@ export class GraphQLClient { variables?: TVariables, requestHeaders?: Record, ): Promise { - const query = print(document); + const prepared = prepareDocument(document, this.schemaIndexProvider()); + const query = print(prepared.document); + const sentVariables = Object.fromEntries( + Object.entries((variables ?? {}) as Record).filter(([name]) => + prepared.variableNames.has(name), + ), + ); // Prepare headers with authentication (enhancedFetch handles cookies automatically) const headers: Record = { @@ -53,7 +71,7 @@ export class GraphQLClient { headers, body: JSON.stringify({ query, - variables: variables ?? {}, + variables: sentVariables, }), }); diff --git a/packages/gitlab-mcp/src/graphql/containerRegistry.ts b/packages/gitlab-mcp/src/graphql/containerRegistry.ts index 7a5d02252..971989386 100644 --- a/packages/gitlab-mcp/src/graphql/containerRegistry.ts +++ b/packages/gitlab-mcp/src/graphql/containerRegistry.ts @@ -29,9 +29,11 @@ const REPOSITORY_LIST_FIELDS = ` createdAt updatedAt `; +// @optional fields are dropped on instances whose schema predates them +// (lastPublishedAt 16.11, publishedAt 16.8, mediaType 17.2). const REPOSITORY_DETAIL_FIELDS = ` ${REPOSITORY_LIST_FIELDS} - lastPublishedAt + lastPublishedAt @optional `; const TAG_FIELDS = ` @@ -43,8 +45,8 @@ const TAG_FIELDS = ` shortRevision totalSize createdAt - publishedAt - mediaType + publishedAt @optional + mediaType @optional `; export interface ContainerRepositoryNode { @@ -69,8 +71,9 @@ export interface ContainerTagNode { shortRevision: string | null; totalSize: string | null; createdAt: string | null; - publishedAt: string | null; - mediaType: string | null; + // Absent on instances whose schema predates them. + publishedAt?: string | null; + mediaType?: string | null; } interface PageInfo { diff --git a/packages/gitlab-mcp/src/graphql/prepare-document.ts b/packages/gitlab-mcp/src/graphql/prepare-document.ts new file mode 100644 index 000000000..9d505aefe --- /dev/null +++ b/packages/gitlab-mcp/src/graphql/prepare-document.ts @@ -0,0 +1,143 @@ +import { + DirectiveNode, + DocumentNode, + Kind, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, + visit, +} from 'graphql'; +import type { SchemaFieldIndex } from '../services/SchemaIntrospector'; + +/** + * Client-side directive marking a field as non-essential: when the connected + * instance's schema lacks it, the field is dropped instead of failing the whole + * query. Never sent to GitLab. + */ +export const OPTIONAL_DIRECTIVE = 'optional'; + +export interface PreparedDocument { + document: DocumentNode; + /** Variables the prepared document still declares; others must not be sent. */ + variableNames: ReadonlySet; +} + +const isOptional = (directives?: readonly DirectiveNode[]): boolean => + directives?.some((d) => d.name.value === OPTIONAL_DIRECTIVE) ?? false; + +const stripOptional = (directives?: readonly DirectiveNode[]): DirectiveNode[] | undefined => + directives?.filter((d) => d.name.value !== OPTIONAL_DIRECTIVE); + +/** + * Drop what the instance cannot answer. An inline fragment on a type the schema + * does not declare can never match, so it always goes. A missing field goes only + * when marked @optional; an essential missing field is left in place so GitLab + * reports it and the caller can fall back or fail loudly. Returns undefined when + * nothing selectable remains. + */ +function pruneSelectionSet( + set: SelectionSetNode, + typeName: string | undefined, + index: SchemaFieldIndex | undefined, +): SelectionSetNode | undefined { + const fields = typeName ? index?.get(typeName) : undefined; + const selections: SelectionNode[] = []; + + for (const selection of set.selections) { + if (selection.kind === Kind.FIELD) { + const optional = isOptional(selection.directives); + const field = fields?.get(selection.name.value); + if (optional && fields && !field && selection.name.value !== '__typename') continue; + + let selectionSet = selection.selectionSet; + if (selectionSet) { + const pruned = pruneSelectionSet(selectionSet, field?.type, index); + if (!pruned && optional) continue; + selectionSet = pruned ?? selectionSet; + } + selections.push({ + ...selection, + directives: stripOptional(selection.directives), + selectionSet, + }); + continue; + } + + if (selection.kind === Kind.INLINE_FRAGMENT) { + const condition = selection.typeCondition?.name.value; + if (condition && index && !index.has(condition)) continue; + const pruned = pruneSelectionSet(selection.selectionSet, condition ?? typeName, index); + if (!pruned) continue; + selections.push({ + ...selection, + directives: stripOptional(selection.directives), + selectionSet: pruned, + }); + continue; + } + + selections.push(selection); + } + + return selections.length > 0 ? { ...set, selections } : undefined; +} + +function prepare(document: DocumentNode, index: SchemaFieldIndex | undefined): PreparedDocument { + const pruned: DocumentNode = { + ...document, + definitions: document.definitions.map((definition) => { + if (definition.kind !== Kind.OPERATION_DEFINITION) return definition; + const root = definition.operation === 'mutation' ? 'Mutation' : 'Query'; + const selectionSet = + pruneSelectionSet(definition.selectionSet, root, index) ?? definition.selectionSet; + return { ...definition, selectionSet }; + }), + }; + + // Variables referenced only by pruned selections must not stay declared: + // GitLab rejects a declared-but-unused variable. + const used = new Set(); + visit(pruned, { + VariableDefinition: () => false, + Variable: (node) => { + used.add(node.name.value); + }, + }); + + const result: DocumentNode = { + ...pruned, + definitions: pruned.definitions.map((definition) => + definition.kind === Kind.OPERATION_DEFINITION + ? ({ + ...definition, + variableDefinitions: definition.variableDefinitions?.filter((v) => + used.has(v.variable.name.value), + ), + } satisfies OperationDefinitionNode) + : definition, + ), + }; + + return { document: result, variableNames: used }; +} + +// Prepared documents are pure functions of (document, schema); both are +// long-lived, so memoise per pair without pinning either in memory. +const NO_SCHEMA = {}; +const cache = new WeakMap>(); + +/** + * Adapt a document to the instance schema (see pruneSelectionSet) and strip the + * client-only @optional directive. Without a schema index nothing is pruned. + */ +export function prepareDocument( + document: DocumentNode, + index: SchemaFieldIndex | undefined, +): PreparedDocument { + const key = index ?? NO_SCHEMA; + let perSchema = cache.get(document); + if (!perSchema) cache.set(document, (perSchema = new WeakMap())); + let prepared = perSchema.get(key); + if (!prepared) perSchema.set(key, (prepared = prepare(document, index))); + return prepared; +} diff --git a/packages/gitlab-mcp/src/graphql/workItems.ts b/packages/gitlab-mcp/src/graphql/workItems.ts index 19fbcb95a..a325c7a13 100644 --- a/packages/gitlab-mcp/src/graphql/workItems.ts +++ b/packages/gitlab-mcp/src/graphql/workItems.ts @@ -545,32 +545,18 @@ export const GET_NAMESPACE_TYPE: TypedDocumentNode< } `; -export const GET_NAMESPACE_WORK_ITEMS: TypedDocumentNode< - { - namespace: { - __typename: string; - fullPath: string; - workItems?: { - nodes: WorkItem[]; - pageInfo: { - hasNextPage: boolean; - endCursor?: string; - }; - } | null; - } | null; - }, - { namespacePath: string; types?: string[]; first?: number; after?: string } -> = gql` - query GetNamespaceWorkItems( - $namespacePath: ID! - $types: [IssueType!] - $first: Int - $after: String - ) { - namespace(fullPath: $namespacePath) { - __typename - fullPath - workItems(types: $types, first: $first, after: $after) { +interface WorkItemListConnection { + nodes: WorkItem[]; + pageInfo: { + hasNextPage: boolean; + endCursor?: string; + }; +} + +type WorkItemListVars = { namespacePath: string; types?: string[]; first?: number; after?: string }; + +// Listing selection shared by the namespace query and its project/group fallbacks. +const WORK_ITEM_LIST_CONNECTION = ` nodes { id iid @@ -658,6 +644,61 @@ export const GET_NAMESPACE_WORK_ITEMS: TypedDocumentNode< hasNextPage endCursor } +`; + +export const GET_NAMESPACE_WORK_ITEMS: TypedDocumentNode< + { + namespace: { + __typename: string; + fullPath: string; + workItems?: WorkItemListConnection | null; + } | null; + }, + WorkItemListVars +> = gql` + query GetNamespaceWorkItems( + $namespacePath: ID! + $types: [IssueType!] + $first: Int + $after: String + ) { + namespace(fullPath: $namespacePath) { + __typename + fullPath + workItems(types: $types, first: $first, after: $after) { + ${WORK_ITEM_LIST_CONNECTION} + } + } + } +`; + +// Fallbacks for instances without Namespace.workItems (GitLab 18.1). +export const LIST_PROJECT_WORK_ITEMS: TypedDocumentNode< + { project: { workItems: WorkItemListConnection | null } | null }, + WorkItemListVars +> = gql` + query ListProjectWorkItems( + $namespacePath: ID! + $types: [IssueType!] + $first: Int + $after: String + ) { + project(fullPath: $namespacePath) { + workItems(types: $types, first: $first, after: $after) { + ${WORK_ITEM_LIST_CONNECTION} + } + } + } +`; + +export const LIST_GROUP_WORK_ITEMS: TypedDocumentNode< + { group: { workItems: WorkItemListConnection | null } | null }, + WorkItemListVars +> = gql` + query ListGroupWorkItems($namespacePath: ID!, $types: [IssueType!], $first: Int, $after: String) { + group(fullPath: $namespacePath) { + workItems(types: $types, first: $first, after: $after) { + ${WORK_ITEM_LIST_CONNECTION} } } } @@ -1329,15 +1370,8 @@ export const GET_PROJECT_WORK_ITEMS: TypedDocumentNode< } `; -// Get work item by namespace + IID (for URL-based lookups) -// Uses the namespace.workItem(iid) query supported since GitLab 16.3 -export const GET_WORK_ITEM_BY_IID: TypedDocumentNode< - { namespace: { workItem: WorkItem | null } | null }, - { namespacePath: string; iid: string } -> = gql` - query GetWorkItemByIid($namespacePath: ID!, $iid: String!) { - namespace(fullPath: $namespacePath) { - workItem(iid: $iid) { +// Work item selection shared by the IID lookup and its project/group fallbacks. +const WORK_ITEM_BY_IID_FIELDS = ` id iid title @@ -1470,6 +1504,49 @@ export const GET_WORK_ITEM_BY_IID: TypedDocumentNode< } } } +`; + +// Get work item by namespace + IID (for URL-based lookups) +export const GET_WORK_ITEM_BY_IID: TypedDocumentNode< + { namespace: { workItem: WorkItem | null } | null }, + { namespacePath: string; iid: string } +> = gql` + query GetWorkItemByIid($namespacePath: ID!, $iid: String!) { + namespace(fullPath: $namespacePath) { + workItem(iid: $iid) { + ${WORK_ITEM_BY_IID_FIELDS} + } + } + } +`; + +// Fallbacks for instances without Namespace.workItem: filter the project or +// group work item listing by IID. +export const GET_PROJECT_WORK_ITEM_BY_IID: TypedDocumentNode< + { project: { workItems: { nodes: WorkItem[] } | null } | null }, + { namespacePath: string; iid: string } +> = gql` + query GetProjectWorkItemByIid($namespacePath: ID!, $iid: String!) { + project(fullPath: $namespacePath) { + workItems(iid: $iid, first: 1) { + nodes { + ${WORK_ITEM_BY_IID_FIELDS} + } + } + } + } +`; + +export const GET_GROUP_WORK_ITEM_BY_IID: TypedDocumentNode< + { group: { workItems: { nodes: WorkItem[] } | null } | null }, + { namespacePath: string; iid: string } +> = gql` + query GetGroupWorkItemByIid($namespacePath: ID!, $iid: String!) { + group(fullPath: $namespacePath) { + workItems(iid: $iid, first: 1) { + nodes { + ${WORK_ITEM_BY_IID_FIELDS} + } } } } @@ -1730,7 +1807,7 @@ export const UPDATE_WORK_ITEM: TypedDocumentNode< ... on WorkItemWidgetStartAndDueDate { startDate dueDate - isFixed + isFixed @optional } ... on WorkItemWidgetHierarchy { parent { @@ -1784,10 +1861,10 @@ export const UPDATE_WORK_ITEM: TypedDocumentNode< healthStatus } ... on WorkItemWidgetProgress { - currentValue - endValue + currentValue @optional + endValue @optional progress - startValue + startValue @optional } ... on WorkItemWidgetColor { color @@ -1885,6 +1962,34 @@ export const GET_WORK_ITEM_TYPES: TypedDocumentNode< } `; +// Fallback for instances without Namespace.workItemTypes (GitLab 17.2). +export const GET_PROJECT_OR_GROUP_WORK_ITEM_TYPES: TypedDocumentNode< + { + project: { workItemTypes: { nodes: { id: string; name: string }[] } } | null; + group: { workItemTypes: { nodes: { id: string; name: string }[] } } | null; + }, + { namespacePath: string } +> = gql` + query GetProjectOrGroupWorkItemTypes($namespacePath: ID!) { + project(fullPath: $namespacePath) { + workItemTypes { + nodes { + id + name + } + } + } + group(fullPath: $namespacePath) { + workItemTypes { + nodes { + id + name + } + } + } + } +`; + // Work item creation input interface for widgets // NOTE: timeTrackingWidget is NOT supported on WorkItemCreateInput by GitLab API // Time tracking must be applied via a follow-up update call after creation diff --git a/packages/gitlab-mcp/src/services/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 9a3ad67e6..36d3339cb 100644 --- a/packages/gitlab-mcp/src/services/ConnectionManager.ts +++ b/packages/gitlab-mcp/src/services/ConnectionManager.ts @@ -260,6 +260,8 @@ export class ConnectionManager { const client = new GraphQLClient(endpoint, clientOptions); const versionDetector = new GitLabVersionDetector(client); const schemaIntrospector = new SchemaIntrospector(client); + // Adapt every query to this instance's schema once it is introspected. + client.setSchemaIndexProvider(() => schemaIntrospector.getCachedSchema()?.fieldIndex); // Create per-URL state entry (assigned to outer `let state` for catch guard) state = { diff --git a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts index bca332cb7..0bcfe2c20 100644 --- a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts +++ b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts @@ -46,16 +46,22 @@ export type CapabilityGate = Pick = { free: 0, premium: 1, ultimate: 2 }; -/** Default requirement applied when a tool/action omits explicit thresholds. */ +/** Default requirement applied when a tool/action omits an explicit tier. */ const DEFAULT_TIER = 'free' as const; -const DEFAULT_MIN_VERSION = '8.0'; /** - * Conservative gate for GitLab-backed tools that declare no requirements at all - * (a tool author forgot to annotate, or it is a future tool). Mirrors the legacy - * "unknown tool" fallback so behaviour does not regress. + * Oldest GitLab release this server supports. Every tool, action and parameter + * requires at least this version; a declared minVersion only matters above it. */ -const UNKNOWN_TOOL_MIN_VERSION = '15.0'; +export const MIN_SUPPORTED_VERSION = '16.0'; + +/** Version a requirement gates on: its own minVersion, never below the supported floor. */ +export function effectiveMinVersion(req: ToolRequirement | undefined): string { + const declared = req?.minVersion; + return declared && parseVersion(declared) > parseVersion(MIN_SUPPORTED_VERSION) + ? declared + : MIN_SUPPORTED_VERSION; +} function isTierSufficient(actual: GitLabTier, required: ToolRequirement['tier']): boolean { const actualLevel = TIER_ORDER[actual] ?? 0; @@ -87,9 +93,7 @@ export function meetsRequirement(req: ToolRequirement, caps: CapabilityGate): bo // landed, so gate it BEFORE the version-unknown fail-open. if (req.requiresAdmin && caps.adminModeActive === false) return false; if (caps.version === 'unknown') return true; - if (parseVersion(caps.version) < parseVersion(req.minVersion ?? DEFAULT_MIN_VERSION)) { - return false; - } + if (parseVersion(caps.version) < parseVersion(effectiveMinVersion(req))) return false; if (!isTierSufficient(caps.tier, req.tier)) return false; return true; } @@ -98,8 +102,7 @@ export function meetsRequirement(req: ToolRequirement, caps: CapabilityGate): bo * Whether a tool is available on the instance for the given (optional) action. * * @param reqs - The tool's declared requirements, or undefined when the tool - * declares none — in which case a conservative >= 15.0 gate applies (matching - * the legacy unknown-tool behaviour). + * declares none, in which case only the supported version floor applies. */ export function isToolAvailable( reqs: ToolRequirements | undefined, @@ -107,10 +110,10 @@ export function isToolAvailable( action?: string, ): boolean { if (!reqs) { - // Unannotated tools have no admin gate; only the conservative version floor. + // Unannotated tools have no admin gate; only the supported version floor. return caps.version === 'unknown' ? true - : parseVersion(caps.version) >= parseVersion(UNKNOWN_TOOL_MIN_VERSION); + : parseVersion(caps.version) >= parseVersion(MIN_SUPPORTED_VERSION); } // Delegate to meetsRequirement so the admin gate applies even when version is // unknown (it short-circuits version/tier internally). @@ -153,15 +156,12 @@ export function getUnmetReason( return 'Requires administrator privileges (admin mode must be active)'; } if (caps.version === 'unknown') return null; - if (!reqs) { - return parseVersion(caps.version) >= parseVersion(UNKNOWN_TOOL_MIN_VERSION) - ? null - : `Requires GitLab ${UNKNOWN_TOOL_MIN_VERSION}+, current version is ${caps.version}`; - } - const req = resolveRequirement(reqs, action); - if (parseVersion(caps.version) < parseVersion(req.minVersion ?? DEFAULT_MIN_VERSION)) { - return `Requires GitLab ${req.minVersion ?? DEFAULT_MIN_VERSION}+, current version is ${caps.version}`; + const req = reqs ? resolveRequirement(reqs, action) : undefined; + const minVersion = effectiveMinVersion(req); + if (parseVersion(caps.version) < parseVersion(minVersion)) { + return `Requires GitLab ${minVersion}+, current version is ${caps.version}`; } + if (!req) return null; if (!isTierSufficient(caps.tier, req.tier)) { return `Requires GitLab ${req.tier ?? DEFAULT_TIER} tier or higher, current tier is ${caps.tier}`; } diff --git a/packages/gitlab-mcp/src/services/SchemaIntrospector.ts b/packages/gitlab-mcp/src/services/SchemaIntrospector.ts index e3f9a5248..cf2820fdb 100644 --- a/packages/gitlab-mcp/src/services/SchemaIntrospector.ts +++ b/packages/gitlab-mcp/src/services/SchemaIntrospector.ts @@ -20,19 +20,68 @@ export interface TypeInfo { enumValues?: Array<{ name: string; description?: string }> | null; } +/** A field of an output type: the named type it resolves to and its argument names. */ +export interface IndexedField { + type: string; + args: ReadonlySet; +} + +/** + * Every type the instance's GraphQL schema declares, with its fields (output + * types) or input fields (input objects). Types without either (unions, enums, + * scalars) map to an empty field map so their existence can still be checked. + */ +export type SchemaFieldIndex = ReadonlyMap>; + export interface SchemaInfo { workItemWidgetTypes: string[]; typeDefinitions: Map; availableFeatures: Set; + /** Absent when introspection failed; callers then cannot adapt to the schema. */ + fieldIndex?: SchemaFieldIndex; +} + +interface IntrospectionTypeRef { + name: string | null; + kind: string; + ofType?: IntrospectionTypeRef | null; } interface IntrospectionType { name: string; kind: string; - fields?: FieldInfo[] | null; + fields?: Array }> | null; + inputFields?: Array<{ name: string }> | null; enumValues?: Array<{ name: string; description?: string }> | null; } +/** Named type at the bottom of a NON_NULL/LIST wrapper chain. */ +function namedType(ref: IntrospectionTypeRef | null | undefined): string { + let current = ref; + while (current && !current.name) current = current.ofType; + return current?.name ?? ''; +} + +function buildFieldIndex(types: IntrospectionType[]): SchemaFieldIndex { + const index = new Map>(); + for (const type of types) { + if (!type.name) continue; + const fields = new Map(); + for (const field of type.fields ?? []) { + fields.set(field.name, { + type: namedType(field.type), + args: new Set((field.args ?? []).map((arg) => arg.name)), + }); + } + // Input object fields, so handlers can tell which mutation inputs exist. + for (const inputField of type.inputFields ?? []) { + fields.set(inputField.name, { type: '', args: new Set() }); + } + index.set(type.name, fields); + } + return index; +} + interface IntrospectionResult { __schema: { types: IntrospectionType[]; @@ -47,15 +96,29 @@ const INTROSPECTION_QUERY = gql` kind fields { name + args { + name + } type { name kind ofType { name kind + ofType { + name + kind + ofType { + name + kind + } + } } } } + inputFields { + name + } enumValues { name description @@ -121,6 +184,7 @@ export class SchemaIntrospector { workItemWidgetTypes, typeDefinitions, availableFeatures, + fieldIndex: buildFieldIndex(types), }; logInfo('GraphQL schema introspection completed', { diff --git a/packages/gitlab-mcp/src/services/WidgetAvailability.ts b/packages/gitlab-mcp/src/services/WidgetAvailability.ts index c025a9922..52d88339b 100644 --- a/packages/gitlab-mcp/src/services/WidgetAvailability.ts +++ b/packages/gitlab-mcp/src/services/WidgetAvailability.ts @@ -2,11 +2,13 @@ import { WorkItemWidgetType, WorkItemWidgetTypes } from '../graphql/workItems'; import { ConnectionManager } from './ConnectionManager'; import { GitLabTier } from './GitLabVersionDetector'; import { parseVersion } from '../utils/version'; +import { effectiveMinVersion } from './InstanceCapabilities'; import { logDebug } from '../logger'; interface WidgetRequirement { tier: GitLabTier; - minVersion: string; + /** Declared only when newer than MIN_SUPPORTED_VERSION. */ + minVersion?: string; } /** @@ -67,44 +69,44 @@ const PARAMETER_WIDGET_MAP: Record = { export class WidgetAvailability { private static widgetRequirements: Record = { // Free tier widgets (available to all) - [WorkItemWidgetTypes.ASSIGNEES]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.DESCRIPTION]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.HIERARCHY]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.LABELS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.MILESTONE]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.NOTES]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.START_AND_DUE_DATE]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.STATUS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.NOTIFICATIONS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.CURRENT_USER_TODOS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.AWARD_EMOJI]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.PARTICIPANTS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.DESIGNS]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.DEVELOPMENT]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.TIME_TRACKING]: { tier: 'free', minVersion: '15.0' }, - [WorkItemWidgetTypes.ERROR_TRACKING]: { tier: 'free', minVersion: '15.0' }, + [WorkItemWidgetTypes.ASSIGNEES]: { tier: 'free' }, + [WorkItemWidgetTypes.DESCRIPTION]: { tier: 'free' }, + [WorkItemWidgetTypes.HIERARCHY]: { tier: 'free' }, + [WorkItemWidgetTypes.LABELS]: { tier: 'free' }, + [WorkItemWidgetTypes.MILESTONE]: { tier: 'free' }, + [WorkItemWidgetTypes.NOTES]: { tier: 'free' }, + [WorkItemWidgetTypes.START_AND_DUE_DATE]: { tier: 'free' }, + [WorkItemWidgetTypes.STATUS]: { tier: 'free' }, + [WorkItemWidgetTypes.NOTIFICATIONS]: { tier: 'free' }, + [WorkItemWidgetTypes.CURRENT_USER_TODOS]: { tier: 'free' }, + [WorkItemWidgetTypes.AWARD_EMOJI]: { tier: 'free' }, + [WorkItemWidgetTypes.PARTICIPANTS]: { tier: 'free' }, + [WorkItemWidgetTypes.DESIGNS]: { tier: 'free' }, + [WorkItemWidgetTypes.DEVELOPMENT]: { tier: 'free' }, + [WorkItemWidgetTypes.TIME_TRACKING]: { tier: 'free' }, + [WorkItemWidgetTypes.ERROR_TRACKING]: { tier: 'free' }, // Free tier widgets (linked items available on CE) - [WorkItemWidgetTypes.LINKED_ITEMS]: { tier: 'free', minVersion: '15.0' }, + [WorkItemWidgetTypes.LINKED_ITEMS]: { tier: 'free' }, // Premium tier widgets - [WorkItemWidgetTypes.WEIGHT]: { tier: 'premium', minVersion: '15.0' }, - [WorkItemWidgetTypes.ITERATION]: { tier: 'premium', minVersion: '15.0' }, - [WorkItemWidgetTypes.PROGRESS]: { tier: 'premium', minVersion: '15.0' }, - [WorkItemWidgetTypes.CRM_CONTACTS]: { tier: 'premium', minVersion: '16.0' }, - [WorkItemWidgetTypes.EMAIL_PARTICIPANTS]: { tier: 'premium', minVersion: '16.0' }, + [WorkItemWidgetTypes.WEIGHT]: { tier: 'premium' }, + [WorkItemWidgetTypes.ITERATION]: { tier: 'premium' }, + [WorkItemWidgetTypes.PROGRESS]: { tier: 'premium' }, + [WorkItemWidgetTypes.CRM_CONTACTS]: { tier: 'premium' }, + [WorkItemWidgetTypes.EMAIL_PARTICIPANTS]: { tier: 'premium' }, [WorkItemWidgetTypes.LINKED_RESOURCES]: { tier: 'premium', minVersion: '16.5' }, // Ultimate tier widgets - [WorkItemWidgetTypes.HEALTH_STATUS]: { tier: 'ultimate', minVersion: '15.0' }, - [WorkItemWidgetTypes.COLOR]: { tier: 'ultimate', minVersion: '15.0' }, + [WorkItemWidgetTypes.HEALTH_STATUS]: { tier: 'ultimate' }, + [WorkItemWidgetTypes.COLOR]: { tier: 'ultimate' }, [WorkItemWidgetTypes.CUSTOM_FIELDS]: { tier: 'ultimate', minVersion: '17.0' }, - [WorkItemWidgetTypes.VULNERABILITIES]: { tier: 'ultimate', minVersion: '15.0' }, + [WorkItemWidgetTypes.VULNERABILITIES]: { tier: 'ultimate' }, // Legacy widgets (may not be available) - [WorkItemWidgetTypes.REQUIREMENT_LEGACY]: { tier: 'ultimate', minVersion: '13.1' }, - [WorkItemWidgetTypes.TEST_REPORTS]: { tier: 'ultimate', minVersion: '13.6' }, - [WorkItemWidgetTypes.VERIFICATION_STATUS]: { tier: 'ultimate', minVersion: '13.1' }, + [WorkItemWidgetTypes.REQUIREMENT_LEGACY]: { tier: 'ultimate' }, + [WorkItemWidgetTypes.TEST_REPORTS]: { tier: 'ultimate' }, + [WorkItemWidgetTypes.VERIFICATION_STATUS]: { tier: 'ultimate' }, }; public static isWidgetAvailable(widget: WorkItemWidgetType, instanceUrl?: string): boolean { @@ -121,8 +123,7 @@ export class WidgetAvailability { // Check version requirement const version = parseVersion(instanceInfo.version); - const minVersion = parseVersion(requirement.minVersion); - if (version < minVersion) { + if (version < parseVersion(effectiveMinVersion(requirement))) { return false; } @@ -198,12 +199,12 @@ export class WidgetAvailability { if (!requirement) continue; // Unknown widget // Check version requirement - const minVersion = parseVersion(requirement.minVersion); - if (parsedVersion < minVersion) { + const requiredVersion = effectiveMinVersion(requirement); + if (parsedVersion < parseVersion(requiredVersion)) { return { parameter: paramName, widget: widgetType, - requiredVersion: requirement.minVersion, + requiredVersion, detectedVersion: instanceVersion, requiredTier: requirement.tier, currentTier: instanceTier, @@ -219,7 +220,7 @@ export class WidgetAvailability { return { parameter: paramName, widget: widgetType, - requiredVersion: requirement.minVersion, + requiredVersion, detectedVersion: instanceVersion, requiredTier: requirement.tier, currentTier: instanceTier, diff --git a/packages/gitlab-mcp/src/types.ts b/packages/gitlab-mcp/src/types.ts index 049b74d3c..d7e14d3cb 100644 --- a/packages/gitlab-mcp/src/types.ts +++ b/packages/gitlab-mcp/src/types.ts @@ -31,7 +31,9 @@ export interface FeatureGate { // Tier/version/admin requirement for a tool, one of its actions, or one of its // parameters. All fields optional: absent tier defaults to 'free', absent -// minVersion to '8.0', absent requiresAdmin to false. Consulted by the registry +// minVersion to MIN_SUPPORTED_VERSION (a lower value is ignored, so declare it +// only for endpoints/params newer than that floor), absent requiresAdmin to +// false. Consulted by the registry // (via InstanceCapabilities) to filter out unsupported tools and strip // restricted parameters, instead of letting them fail at call time. (Action-level // entries also drive tier-badge documentation.) diff --git a/packages/gitlab-mcp/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index 64fa218db..264eb0656 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -1,5 +1,6 @@ import { enhancedFetch } from './fetch'; import { transliterate } from 'transliteration'; +import { instanceAtLeast } from '../entities/instance-version'; /** * User query type detected by pattern analysis @@ -99,34 +100,57 @@ export function analyzeQuery(query: string): QueryPattern { }; } +interface ListedUser { + state?: string; + /** Exposed only in the full user entity (administrators); absent otherwise. */ + bot?: boolean; +} + /** - * Make GitLab Users API call with given parameters + * GET /users with the user-type filters GitLab added in 17.3 (humans, + * exclude_humans, exclude_active). Older instances ignore them, so there they + * are emulated: exclude_active on each user's state, humans by excluding project + * bots server-side and any user flagged as a bot. exclude_humans needs the bot + * flag, which only the full entity carries. */ -async function callUsersAPI(params: UserSearchParams): Promise { - const queryParams = new URLSearchParams(); +export async function fetchUsers(params: Record): Promise { + const { humans, exclude_humans, exclude_active, ...rest } = params; + const native = instanceAtLeast('17.3'); + const query: Record = native + ? params + : { ...rest, ...(humans ? { without_project_bots: true } : {}) }; - // Add common defaults for better results - const defaultParams = { - active: true, - humans: true, - ...params, - }; - - Object.entries(defaultParams).forEach(([key, value]) => { - if (value !== undefined) { - queryParams.set(key, String(value)); - } + const queryParams = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined) queryParams.set(key, String(value)); }); - - const apiUrl = `${process.env.GITLAB_API_URL}/api/v4/users?${queryParams}`; - const response = await enhancedFetch(apiUrl); - + const response = await enhancedFetch(`${process.env.GITLAB_API_URL}/api/v4/users?${queryParams}`); if (!response.ok) { throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); } + const body = (await response.json()) as unknown; + const users = Array.isArray(body) ? (body as ListedUser[]) : []; + if (native) return users; + + if (exclude_humans && users.some((user) => user.bot === undefined)) { + throw new Error( + 'Filtering to bot users needs GitLab 17.3+, or an administrator token on older instances', + ); + } + return users.filter( + (user) => + !(humans && user.bot === true) && + !(exclude_humans && user.bot === false) && + !(exclude_active && user.state === 'active'), + ); +} - const users = (await response.json()) as unknown; - return Array.isArray(users) ? (users as unknown[]) : []; +/** + * Make GitLab Users API call with given parameters + */ +async function callUsersAPI(params: UserSearchParams): Promise { + // Add common defaults for better results + return fetchUsers({ active: true, humans: true, ...params }); } /** diff --git a/packages/gitlab-mcp/src/utils/workItemTypes.ts b/packages/gitlab-mcp/src/utils/workItemTypes.ts index 2113c0024..6417e0179 100644 --- a/packages/gitlab-mcp/src/utils/workItemTypes.ts +++ b/packages/gitlab-mcp/src/utils/workItemTypes.ts @@ -1,5 +1,6 @@ import { ConnectionManager } from '../services/ConnectionManager'; -import { GET_WORK_ITEM_TYPES } from '../graphql/workItems'; +import { GET_PROJECT_OR_GROUP_WORK_ITEM_TYPES, GET_WORK_ITEM_TYPES } from '../graphql/workItems'; +import { graphqlSupports } from '../entities/instance-version'; // Define interface for work item type objects interface WorkItemType { @@ -16,11 +17,14 @@ export async function getWorkItemTypes(namespace: string): Promise & { + id: number; + not_applied?: Array<{ setting: string; requested: unknown; current?: unknown }>; +}; + +/** Settings the tool catalog offers on this instance (tier/version gating applied). */ +function offeredSettings(toolName: string, tracked: Readonly>): string[] { + const info = ConnectionManager.getInstance().getInstanceInfo(); + const restricted = getRestrictedParameters(coreToolRegistry.get(toolName)!.requirements, { + version: info.version, + tier: info.tier, + }); + return Object.keys(tracked).filter((name) => !restricted.includes(name)); +} + +/** + * For each requested setting: confirmed by the fresh read, or listed in + * not_applied with the fresh read still showing a different value. + */ +function expectAppliedOrReported( + requested: Record, + updated: Entity, + fresh: Record, +): void { + const reported = new Map((updated.not_applied ?? []).map((entry) => [entry.setting, entry])); + for (const [setting, value] of Object.entries(requested)) { + const entry = reported.get(setting); + if (entry) { + expect(entry.requested).toBe(value); + expect(fresh[setting]).not.toBe(value); + } else { + expect(fresh[setting]).toBe(value); + } + } +} + +describe('GitLab Duo settings - GitLab Integration', () => { + let helper: IntegrationTestHelper; + + beforeAll(async () => { + helper = await initIntegrationHelper(); + }); + + it('applies or reports every offered project Duo setting', async () => { + const offered = offeredSettings('manage_project', PROJECT_DUO_SETTINGS); + if (offered.length === 0) { + console.log('Instance tier/version offers no project Duo settings - nothing to verify'); + return; + } + const projectId = String(getTestProject()!.id); + const getProject = async () => + (await helper.executeTool( + 'browse_projects', + BrowseProjectsSchema.parse({ action: 'get', project_id: projectId }), + )) as Record; + + // Flip every offered setting so an unchanged value cannot pass as "applied". + const before = await getProject(); + const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); + + const updated = (await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), + )) as Entity; + console.log( + `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); + + expectAppliedOrReported(requested, updated, await getProject()); + }, 60000); + + it('applies or reports group automatic Duo code review', async () => { + const offered = offeredSettings('manage_namespace', GROUP_DUO_SETTINGS); + if (offered.length === 0) { + console.log('Instance tier/version offers no group Duo settings - nothing to verify'); + return; + } + const groupId = String(getTestGroup()!.id); + // No tool reads a group's settings (browse_namespaces reads /namespaces), so + // the independent read goes to the groups endpoint directly. + const getGroup = async () => { + const response = await enhancedFetch( + `${process.env.GITLAB_API_URL}/api/v4/groups/${groupId}?with_projects=false`, + ); + expect(response.ok).toBe(true); + return (await response.json()) as Record; + }; + + const before = await getGroup(); + const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); + + const updated = (await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), + )) as Entity; + console.log( + `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); + + expectAppliedOrReported(requested, updated, await getGroup()); + }, 60000); +}); diff --git a/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts b/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts index 58c31f896..8ad9c94b8 100644 --- a/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts @@ -93,8 +93,8 @@ describe('list-tools script', () => { it('applies documented requirement defaults in json output', async () => { // A tool with requirements but omitted tier/minVersion must report the - // documented defaults (free/8.0), not 'unknown'/undefined; a tool with no - // requirements at all stays 'unknown'/undefined. + // documented defaults (free / supported floor 16.0), not 'unknown'/undefined; + // a tool with no requirements at all stays 'unknown'/undefined. process.argv = ['node', 'list-tools.ts', '--json']; mockManager.getAllToolDefinitionsTierless.mockReturnValue([ { @@ -121,7 +121,7 @@ describe('list-tools script', () => { const none = output.find((t: { name: string }) => t.name === 'no_req_tool'); expect(partial.tier).toBe('free'); - expect(partial.minVersion).toBe('8.0'); + expect(partial.minVersion).toBe('16.0'); expect(none.tier).toBe('unknown'); expect(none.minVersion).toBeUndefined(); }); diff --git a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts index 46eb134f1..d15ed5c1b 100644 --- a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts @@ -9,6 +9,8 @@ import { lastFetchCall as lastCall, mockEnhancedFetch, } from '../../helpers/fetch-mock'; +import { ConnectionManager } from '../../../../src/services/ConnectionManager'; +import type { GitLabInstanceInfo } from '../../../../src/services/GitLabVersionDetector'; jest.mock('../../../../src/utils/fetch', () => ({ enhancedFetch: jest.fn(), @@ -34,8 +36,8 @@ describe('Access Tokens Registry', () => { }); it('declares the Free-tier requirement and USE_ACCESS_TOKENS gate on both tools', () => { - expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '13.0' }); - expect(manage().requirements?.default).toEqual({ tier: 'free', minVersion: '13.0' }); + expect(browse().requirements?.default).toEqual({ tier: 'free' }); + expect(manage().requirements?.default).toEqual({ tier: 'free' }); expect(browse().gate).toEqual({ envVar: 'USE_ACCESS_TOKENS', defaultValue: true }); expect(manage().gate).toEqual({ envVar: 'USE_ACCESS_TOKENS', defaultValue: true }); }); @@ -77,6 +79,60 @@ describe('Access Tokens Registry', () => { expect(url).toContain('/groups/my-group/access_tokens'); }); + describe('state filter on project/group token lists (server-side from GitLab 17.2)', () => { + // Older instances ignore `state` and return every token; the tool must + // apply the filter itself there rather than return an unfiltered list. + const atVersion = (version: string) => + jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version, tier: 'free' } as GitLabInstanceInfo); + + afterEach(() => jest.restoreAllMocks()); + + const tokens = [ + { id: 1, active: true }, + { id: 2, active: false }, + ]; + + it('filters active project tokens client-side before 17.2', async () => { + atVersion('17.1.0'); + mockOk(tokens); + const result = await browse().handler({ + action: 'list_project', + project_id: 'p', + state: 'active', + }); + expect(result).toEqual([{ id: 1, active: true }]); + // The ignored parameter is not sent. + expect(lastCall()[0]).not.toContain('state='); + }); + + it('filters inactive group tokens client-side before 17.2', async () => { + atVersion('17.1.0'); + mockOk(tokens); + const result = await browse().handler({ + action: 'list_group', + group_id: 'g', + state: 'inactive', + }); + expect(result).toEqual([{ id: 2, active: false }]); + }); + + it('sends the state filter from 17.2', async () => { + atVersion('17.2.0'); + mockOk([]); + await browse().handler({ action: 'list_project', project_id: 'p', state: 'active' }); + expect(lastCall()[0]).toContain('state=active'); + }); + + it('lists without a state filter on older instances', async () => { + atVersion('16.0.0'); + mockOk([]); + await browse().handler({ action: 'list_group', group_id: 'g' }); + expect(lastCall()[0]).toContain('/groups/g/access_tokens'); + }); + }); + it('get without a scope reads a personal token by id', async () => { mockOk({ id: 7 }); await browse().handler({ action: 'get', token_id: 7 }); diff --git a/packages/gitlab-mcp/tests/unit/entities/core/duo-settings.test.ts b/packages/gitlab-mcp/tests/unit/entities/core/duo-settings.test.ts new file mode 100644 index 000000000..ede916008 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/core/duo-settings.test.ts @@ -0,0 +1,315 @@ +/** + * GitLab Duo settings on manage_project / manage_namespace update. + * + * GitLab answers 200 while dropping a Duo setting the instance cannot honour + * (missing add-on, licensed feature or feature flag), so a plain pass-through + * would report success for a setting that never changed. These tests pin the + * detection of such drops, the request body, and the tier/version gating that + * keeps the parameters off instances where GitLab does not accept them at all. + */ + +import { + GROUP_DUO_SETTINGS, + PROJECT_DUO_SETTINGS, + withUnappliedSettings, +} from '../../../../src/entities/core/duo-settings'; +import { coreToolRegistry } from '../../../../src/entities/core/registry'; +import { getRestrictedParameters } from '../../../../src/services/InstanceCapabilities'; +import { installFetchMock, lastFetchCall, mockOk } from '../../helpers/fetch-mock'; + +jest.mock('../../../../src/utils/fetch', () => ({ + enhancedFetch: jest.fn(), +})); + +jest.mock('../../../../src/config', () => ({ + isActionDenied: jest.fn(() => false), +})); + +installFetchMock(); + +const DUO_PROJECT_PARAMS = Object.keys(PROJECT_DUO_SETTINGS); + +describe('withUnappliedSettings', () => { + it('returns the response untouched when every requested setting was applied', () => { + const response = { id: 1, auto_duo_code_review_enabled: true }; + + const result = withUnappliedSettings( + { auto_duo_code_review_enabled: true }, + response, + PROJECT_DUO_SETTINGS, + ); + + // Same reference: no `not_applied` key is added on the success path. + expect(result).toBe(response); + }); + + it('reports a setting GitLab kept at its previous value, with the current value', () => { + const result = withUnappliedSettings( + { auto_duo_code_review_enabled: true }, + { id: 1, auto_duo_code_review_enabled: false }, + PROJECT_DUO_SETTINGS, + ); + + expect(result).toEqual({ + id: 1, + auto_duo_code_review_enabled: false, + not_applied: [ + { + setting: 'auto_duo_code_review_enabled', + requested: true, + current: false, + requires: PROJECT_DUO_SETTINGS.auto_duo_code_review_enabled, + }, + ], + }); + }); + + it('reports a setting the response does not expose at all, without a current value', () => { + // GitLab hides the attribute from the entity when the gating add-on or flag + // is off, which is exactly when it also drops the write. + const result = withUnappliedSettings( + { duo_secret_detection_fp_enabled: true }, + { id: 1 }, + PROJECT_DUO_SETTINGS, + ) as { not_applied: Array> }; + + expect(result.not_applied).toEqual([ + { + setting: 'duo_secret_detection_fp_enabled', + requested: true, + requires: PROJECT_DUO_SETTINGS.duo_secret_detection_fp_enabled, + }, + ]); + expect(result.not_applied[0]).not.toHaveProperty('current'); + }); + + it('reports a disable request that GitLab left enabled', () => { + // The negative direction: turning a setting off must be verified too, not + // only turning it on. + const result = withUnappliedSettings( + { duo_remote_flows_enabled: false }, + { id: 1, duo_remote_flows_enabled: true }, + PROJECT_DUO_SETTINGS, + ) as { not_applied: Array> }; + + expect(result.not_applied).toEqual([ + expect.objectContaining({ + setting: 'duo_remote_flows_enabled', + requested: false, + current: true, + }), + ]); + }); + + it('lists only the dropped settings when some were applied and some were not', () => { + const result = withUnappliedSettings( + { auto_duo_code_review_enabled: true, duo_sast_fp_detection_enabled: true }, + { id: 1, auto_duo_code_review_enabled: false, duo_sast_fp_detection_enabled: true }, + PROJECT_DUO_SETTINGS, + ) as { not_applied: Array<{ setting: string }> }; + + expect(result.not_applied.map((entry) => entry.setting)).toEqual([ + 'auto_duo_code_review_enabled', + ]); + }); + + it('ignores requested fields that are not tracked Duo settings', () => { + // `name` is not echoed back as requested here; it must not produce a report, + // otherwise every non-Duo update would carry noise. + const response = { id: 1, name: 'normalized' }; + + const result = withUnappliedSettings({ name: 'raw name' }, response, PROJECT_DUO_SETTINGS); + + expect(result).toBe(response); + }); + + it('ignores tracked settings that were not requested', () => { + const response = { id: 1, auto_duo_code_review_enabled: false }; + + const result = withUnappliedSettings({ name: 'x' }, response, PROJECT_DUO_SETTINGS); + + expect(result).toBe(response); + }); + + it.each([ + ['null', null], + ['an array', [{ id: 1 }]], + ['a string', 'ok'], + ])('passes %s through unchanged', (_label, response) => { + expect( + withUnappliedSettings({ auto_duo_code_review_enabled: true }, response, PROJECT_DUO_SETTINGS), + ).toBe(response); + }); +}); + +describe('manage_project update with Duo settings', () => { + it('sends the Duo settings and reports the one GitLab dropped', async () => { + mockOk({ + id: 7, + auto_duo_code_review_enabled: false, + duo_sast_fp_detection_enabled: true, + }); + + const result = await coreToolRegistry.get('manage_project')!.handler({ + action: 'update', + project_id: 'my-group/my-project', + auto_duo_code_review_enabled: true, + duo_sast_fp_detection_enabled: true, + }); + + const [url, init] = lastFetchCall(); + expect(url).toBe('https://gitlab.example.com/api/v4/projects/my-group%2Fmy-project'); + expect(init?.method).toBe('PUT'); + const body = new URLSearchParams(init?.body as string); + expect(body.get('auto_duo_code_review_enabled')).toBe('true'); + expect(body.get('duo_sast_fp_detection_enabled')).toBe('true'); + + expect(result).toEqual({ + id: 7, + auto_duo_code_review_enabled: false, + duo_sast_fp_detection_enabled: true, + not_applied: [ + { + setting: 'auto_duo_code_review_enabled', + requested: true, + current: false, + requires: PROJECT_DUO_SETTINGS.auto_duo_code_review_enabled, + }, + ], + }); + }); + + it('coerces string booleans before comparing with the response', async () => { + // Agents often send "true"; after coercion it must match GitLab's boolean + // and not be reported as dropped. + mockOk({ id: 7, duo_remote_flows_enabled: true }); + + const result = await coreToolRegistry.get('manage_project')!.handler({ + action: 'update', + project_id: '7', + duo_remote_flows_enabled: 'true', + }); + + expect(result).toEqual({ id: 7, duo_remote_flows_enabled: true }); + }); + + it('accepts every tracked Duo setting in the update schema', async () => { + mockOk({ id: 7 }); + + const args = Object.fromEntries(DUO_PROJECT_PARAMS.map((name) => [name, false])); + await coreToolRegistry.get('manage_project')!.handler({ + action: 'update', + project_id: '7', + ...args, + }); + + // Zod would strip an undeclared key, so its presence in the body proves the + // schema declares it. + const body = new URLSearchParams(lastFetchCall()[1]?.body as string); + for (const name of DUO_PROJECT_PARAMS) { + expect(body.get(name)).toBe('false'); + } + }); +}); + +describe('manage_namespace update with Duo settings', () => { + it('reports automatic Duo code review that GitLab dropped for the group', async () => { + // Group entity omits the attribute when the setting is unavailable. + mockOk({ id: 5, name: 'grp' }); + + const result = await coreToolRegistry.get('manage_namespace')!.handler({ + action: 'update', + group_id: 'grp', + auto_duo_code_review_enabled: true, + }); + + const body = new URLSearchParams(lastFetchCall()[1]?.body as string); + expect(body.get('auto_duo_code_review_enabled')).toBe('true'); + expect(result).toEqual({ + id: 5, + name: 'grp', + not_applied: [ + { + setting: 'auto_duo_code_review_enabled', + requested: true, + requires: GROUP_DUO_SETTINGS.auto_duo_code_review_enabled, + }, + ], + }); + }); + + it('returns the group unchanged when automatic Duo code review was applied', async () => { + mockOk({ id: 5, auto_duo_code_review_enabled: true }); + + const result = await coreToolRegistry.get('manage_namespace')!.handler({ + action: 'update', + group_id: 'grp', + auto_duo_code_review_enabled: true, + }); + + expect(result).toEqual({ id: 5, auto_duo_code_review_enabled: true }); + }); +}); + +describe('Duo parameter gating', () => { + const projectReqs = () => coreToolRegistry.get('manage_project')!.requirements; + const groupReqs = () => coreToolRegistry.get('manage_namespace')!.requirements; + const restrictedDuo = (version: string, tier: 'free' | 'premium' | 'ultimate') => + getRestrictedParameters(projectReqs(), { version, tier }).filter((name) => + DUO_PROJECT_PARAMS.includes(name), + ); + + it('gates every tracked Duo setting', () => { + // A tracked setting without a requirement would be offered on instances + // whose API rejects or ignores it. + const params = projectReqs()?.parameters ?? {}; + for (const name of DUO_PROJECT_PARAMS) { + expect(params[name]).toBeDefined(); + } + for (const name of Object.keys(GROUP_DUO_SETTINGS)) { + expect(groupReqs()?.parameters?.[name]).toBeDefined(); + } + }); + + it('offers all Duo settings on an Ultimate instance recent enough for all of them', () => { + expect(restrictedDuo('19.2.0', 'ultimate')).toEqual([]); + }); + + it('strips settings newer than the instance version', () => { + // 19.0 predates duo_dependency_bump_breaking_changes_enabled (19.2) only. + expect(restrictedDuo('19.0.0', 'ultimate')).toEqual([ + 'duo_dependency_bump_breaking_changes_enabled', + ]); + // 18.9 also lacks secret detection FP (18.10); numeric compare, not lexical. + expect(new Set(restrictedDuo('18.9.0', 'ultimate'))).toEqual( + new Set(['duo_secret_detection_fp_enabled', 'duo_dependency_bump_breaking_changes_enabled']), + ); + }); + + it('keeps only the Premium Duo settings on a Premium instance', () => { + expect(new Set(restrictedDuo('19.2.0', 'premium'))).toEqual( + new Set([ + 'duo_sast_fp_detection_enabled', + 'duo_sast_vr_workflow_enabled', + 'duo_secret_detection_fp_enabled', + 'duo_dependency_bump_breaking_changes_enabled', + ]), + ); + }); + + it('strips every Duo setting on a Free instance', () => { + expect(new Set(restrictedDuo('19.2.0', 'free'))).toEqual(new Set(DUO_PROJECT_PARAMS)); + }); + + it('strips group automatic Duo code review before 18.7', () => { + expect(getRestrictedParameters(groupReqs(), { version: '18.6.0', tier: 'ultimate' })).toEqual([ + 'auto_duo_code_review_enabled', + ]); + expect(getRestrictedParameters(groupReqs(), { version: '18.7.0', tier: 'ultimate' })).toEqual( + [], + ); + expect( + getRestrictedParameters(groupReqs(), { version: '18.7.0', tier: 'premium' }), + ).not.toContain('auto_duo_code_review_enabled'); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts index 87fedad1a..c7ac2b353 100644 --- a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts @@ -17,6 +17,8 @@ jest.mock('../../../../src/utils/fetch', () => ({ // Mock smart user search jest.mock('../../../../src/utils/smart-user-search', () => ({ + // fetchUsers stays real: it goes through the mocked enhancedFetch. + ...jest.requireActual('../../../../src/utils/smart-user-search'), smartUserSearch: jest.fn(), })); @@ -1185,6 +1187,65 @@ describe('Core Registry', () => { expect(calledUrl).toContain('unidiff=true'); }); + it('adds unified-diff headers itself before GitLab 16.5', async () => { + // Older instances ignore `unidiff`; the tool reproduces GitLab's headers + // (Gitlab::Git::Diff#unidiff) instead of returning bare hunks. + const spy = jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version: '16.4.0', tier: 'free' } as GitLabInstanceInfo); + try { + mockEnhancedFetch.mockResolvedValueOnce( + okJson([ + { + diff: '@@ -1 +1 @@\n-a\n+b\n', + old_path: 'x', + new_path: 'x', + new_file: false, + deleted_file: false, + }, + { + diff: '@@ -0,0 +1 @@\n+n\n', + old_path: 'n', + new_path: 'n', + new_file: true, + deleted_file: false, + }, + { + diff: '@@ -1 +0,0 @@\n-d\n', + old_path: 'd', + new_path: 'd', + new_file: false, + deleted_file: true, + }, + { + diff: 'Binary files differ\n', + old_path: 'b', + new_path: 'b', + new_file: false, + deleted_file: false, + }, + ]), + ); + + const result = (await coreToolRegistry.get('browse_commits')!.handler({ + action: 'diff', + project_id: '123', + sha: 'abc123', + unidiff: true, + })) as Array<{ diff: string }>; + + expect(mockEnhancedFetch.mock.calls[0][0]).not.toContain('unidiff'); + expect(result.map((d) => d.diff)).toEqual([ + '--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n', + '--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+n\n', + '--- a/d\n+++ /dev/null\n@@ -1 +0,0 @@\n-d\n', + 'Binary files differ\n', + ]); + } finally { + spy.mockRestore(); + } + }); + it('should handle API error for list action', async () => { // Test: Error handling for commit list mockEnhancedFetch.mockResolvedValueOnce({ @@ -2108,6 +2169,28 @@ describe('Core Registry', () => { expect(result).toEqual({ id: 1, marked_for_deletion_on: null }); }); + it('refuses project restore on GitLab Free before 17.11 but allows it on Premium', async () => { + // Free (CE) got the restore endpoint in 17.11; Premium had it before 16.0. + const spy = jest.spyOn(ConnectionManager.getInstance(), 'getInstanceInfo'); + try { + spy.mockReturnValue({ version: '17.10.0', tier: 'free' } as GitLabInstanceInfo); + const tool = coreToolRegistry.get('manage_project'); + await expect(tool!.handler({ action: 'restore', project_id: '1' })).rejects.toThrow( + 'Project restore on GitLab Free requires GitLab 17.11+', + ); + expect(mockEnhancedFetch).not.toHaveBeenCalled(); + + spy.mockReturnValue({ version: '17.0.0', tier: 'premium' } as GitLabInstanceInfo); + mockEnhancedFetch.mockResolvedValueOnce(okJson({ id: 1, marked_for_deletion_on: null })); + await expect(tool!.handler({ action: 'restore', project_id: '1' })).resolves.toEqual({ + id: 1, + marked_for_deletion_on: null, + }); + } finally { + spy.mockRestore(); + } + }); + it('should surface a 404 when the project is purged or not found', async () => { mockEnhancedFetch.mockResolvedValueOnce({ ok: false, @@ -2268,16 +2351,29 @@ describe('Core Registry', () => { ); }); - it('rejects group restore on GitLab below 18.0 with a clear message', async () => { - const spy = jest - .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') - .mockReturnValue({ version: '17.11.0', tier: 'free' } as GitLabInstanceInfo); + it('refuses group restore on GitLab Free before 17.11 but allows Premium and later Free', async () => { + // Premium (EE) had group restore before 16.0; Free (CE) got the route in 17.11. + const spy = jest.spyOn(ConnectionManager.getInstance(), 'getInstanceInfo'); try { const tool = coreToolRegistry.get('manage_namespace'); + spy.mockReturnValue({ version: '17.10.0', tier: 'free' } as GitLabInstanceInfo); await expect(tool!.handler({ action: 'restore', group_id: 'old-group' })).rejects.toThrow( - 'Group restore requires GitLab 18.0+', + 'Group restore on GitLab Free requires GitLab 17.11+', ); expect(mockEnhancedFetch).not.toHaveBeenCalled(); + + for (const info of [ + { version: '17.11.0', tier: 'free' }, + { version: '17.0.0', tier: 'premium' }, + ]) { + spy.mockReturnValue(info as GitLabInstanceInfo); + mockEnhancedFetch.mockResolvedValueOnce( + okJson({ id: 5, marked_for_deletion_on: null }), + ); + await expect( + tool!.handler({ action: 'restore', group_id: 'old-group' }), + ).resolves.toEqual({ id: 5, marked_for_deletion_on: null }); + } } finally { spy.mockRestore(); } diff --git a/packages/gitlab-mcp/tests/unit/entities/deploy-keys/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/deploy-keys/registry.test.ts index e181f3238..9221b65b9 100644 --- a/packages/gitlab-mcp/tests/unit/entities/deploy-keys/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/deploy-keys/registry.test.ts @@ -32,8 +32,8 @@ describe('Deploy Keys Registry', () => { }); it('declares the Free-tier requirement on both tools', () => { - expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '8.0' }); - expect(manage().requirements?.default).toEqual({ tier: 'free', minVersion: '8.0' }); + expect(browse().requirements?.default).toEqual({ tier: 'free' }); + expect(manage().requirements?.default).toEqual({ tier: 'free' }); }); it('is gated by the shared USE_CI_TOKENS umbrella flag', () => { diff --git a/packages/gitlab-mcp/tests/unit/entities/environments/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/environments/registry.test.ts index ef87a2b08..5004d57e0 100644 --- a/packages/gitlab-mcp/tests/unit/entities/environments/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/environments/registry.test.ts @@ -35,8 +35,8 @@ describe('Environments Registry', () => { }); it('declares the Free-tier requirement on both tools', () => { - expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '8.0' }); - expect(manage().requirements?.default).toEqual({ tier: 'free', minVersion: '8.0' }); + expect(browse().requirements?.default).toEqual({ tier: 'free' }); + expect(manage().requirements?.default).toEqual({ tier: 'free' }); }); it('is gated by USE_ENVIRONMENTS', () => { diff --git a/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts index d95778139..e73c0c723 100644 --- a/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts @@ -42,9 +42,11 @@ describe('Job Token Scope Registry', () => { }); it('declares free-tier requirements with allowlist minVersions', () => { - expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '15.9' }); - expect(browse().requirements?.actions?.list_groups?.minVersion).toBe('16.0'); - expect(manage().requirements?.actions?.add_group?.minVersion).toBe('16.0'); + // The job token scope API landed in 16.1, its group allowlist in 16.10. + expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '16.1' }); + expect(browse().requirements?.actions?.list_groups?.minVersion).toBe('16.10'); + expect(manage().requirements?.actions?.add_group?.minVersion).toBe('16.10'); + expect(manage().requirements?.actions?.remove_group?.minVersion).toBe('16.10'); }); it('is gated by the shared USE_CI_TOKENS umbrella flag', () => { diff --git a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts index 13fae06d1..4f7cd7195 100644 --- a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts @@ -17,7 +17,8 @@ import { } from '../../../../src/graphql/runners'; const mockClient = { request: jest.fn() }; -const mockGitlab = { post: jest.fn() }; +const mockGitlab = { post: jest.fn(), get: jest.fn() }; +const mockGraphqlSupports = jest.fn(() => true); jest.mock('../../../../src/services/ConnectionManager', () => ({ ConnectionManager: { @@ -25,7 +26,14 @@ jest.mock('../../../../src/services/ConnectionManager', () => ({ }, })); jest.mock('../../../../src/utils/gitlab-api', () => ({ - gitlab: { post: (...args: unknown[]) => mockGitlab.post(...args) }, + ...jest.requireActual('../../../../src/utils/gitlab-api'), + gitlab: { + post: (...args: unknown[]) => mockGitlab.post(...args), + get: (...args: unknown[]) => mockGitlab.get(...args), + }, +})); +jest.mock('../../../../src/entities/instance-version', () => ({ + graphqlSupports: (...args: unknown[]) => mockGraphqlSupports(...(args as [])), })); const browse = () => runnersToolRegistry.get('browse_runners')!; @@ -35,6 +43,8 @@ const RUNNER_GID = 'gid://gitlab/Ci::Runner/7'; beforeEach(() => { mockClient.request.mockReset(); mockGitlab.post.mockReset(); + mockGitlab.get.mockReset(); + mockGraphqlSupports.mockReturnValue(true); }); describe('runners registry', () => { @@ -67,6 +77,85 @@ describe('runners registry', () => { expect(res.nodes).toEqual([]); }); + describe('list_owned without currentUser.runners (before GitLab 18.3)', () => { + beforeEach(() => mockGraphqlSupports.mockReturnValue(false)); + + it('reads the REST owned-runners list, shaped like the GraphQL connection', async () => { + mockGitlab.get.mockResolvedValueOnce([ + { + id: 7, + description: 'ci-a', + runner_type: 'project_type', + status: 'online', + paused: false, + }, + { + id: 8, + description: 'ci-b', + runner_type: 'group_type', + status: 'offline', + paused: true, + }, + ]); + + const res = (await browse().handler({ + action: 'list_owned', + status: 'ONLINE', + type: 'PROJECT_TYPE', + first: 2, + })) as { nodes: Array>; pageInfo: Record }; + + expect(mockClient.request).not.toHaveBeenCalled(); + const [path, opts] = mockGitlab.get.mock.calls[0]; + expect(path).toBe('runners'); + // REST expects lowercase enum values and page-number pagination. + expect(opts.query).toMatchObject({ + status: 'online', + type: 'project_type', + per_page: 2, + page: 1, + }); + expect(res.nodes[0]).toMatchObject({ + id: 7, + runnerType: 'PROJECT_TYPE', + status: 'ONLINE', + paused: false, + }); + // A full page means there may be more; the cursor is the next page. + expect(res.pageInfo).toEqual({ hasNextPage: true, endCursor: '2' }); + }); + + it('filters by search client-side and ends pagination on a short page', async () => { + mockGitlab.get.mockResolvedValueOnce([ + { + id: 7, + description: 'Deploy runner', + runner_type: 'project_type', + status: 'online', + paused: false, + }, + { + id: 8, + description: 'build', + runner_type: 'project_type', + status: 'online', + paused: false, + }, + ]); + + const res = (await browse().handler({ + action: 'list_owned', + search: 'deploy', + after: '3', + })) as { nodes: Array<{ id: number }>; pageInfo: Record }; + + expect(mockGitlab.get.mock.calls[0][1].query).toMatchObject({ page: 3 }); + expect(mockGitlab.get.mock.calls[0][1].query).not.toHaveProperty('search'); + expect(res.nodes.map((n) => n.id)).toEqual([7]); + expect(res.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + }); + }); + it('list_project queries by full path', async () => { mockClient.request.mockResolvedValueOnce({ project: { runners: { nodes: [] } } }); await browse().handler({ action: 'list_project', project_id: 'g/p' }); diff --git a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts index aa62888de..84fb9b0a3 100644 --- a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts @@ -6,6 +6,8 @@ import { } from '../../../../src/entities/webhooks/registry'; import { enhancedFetch } from '../../../../src/utils/fetch'; import * as config from '../../../../src/config'; +import { ConnectionManager } from '../../../../src/services/ConnectionManager'; +import type { GitLabInstanceInfo } from '../../../../src/services/GitLabVersionDetector'; // Mock enhancedFetch to avoid actual API calls jest.mock('../../../../src/utils/fetch', () => ({ @@ -365,6 +367,27 @@ describe('Webhooks Registry', () => { expect(result).toBeDefined(); }); + it('refuses to test a group webhook before GitLab 17.1 without calling GitLab', async () => { + // The group hook test endpoint landed in 17.1 (project hooks in 16.11). + const spy = jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version: '17.0.0', tier: 'premium' } as GitLabInstanceInfo); + try { + await expect( + webhooksToolRegistry.get('manage_webhook')!.handler({ + action: 'test', + scope: 'group', + groupId: 'g', + hookId: 1, + trigger: 'push_events', + }), + ).rejects.toThrow('Testing a group webhook requires GitLab 17.1+'); + expect(mockEnhancedFetch).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + it('should require url for create action', async () => { const tool = webhooksToolRegistry.get('manage_webhook'); expect(tool).toBeDefined(); diff --git a/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts new file mode 100644 index 000000000..0c43a265e --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts @@ -0,0 +1,202 @@ +/** + * Work item tools on instances whose GraphQL schema predates the namespace-level + * queries and the richer create input. Instead of hiding the actions, the tools + * fall back to project/group queries and apply widgets the create input lacks + * through a follow-up update; only what GitLab cannot do at all is refused. + */ + +import { workitemsToolRegistry } from '../../../../src/entities/workitems/registry'; +import { + GET_GROUP_WORK_ITEM_BY_IID, + GET_NAMESPACE_WORK_ITEMS, + GET_PROJECT_WORK_ITEM_BY_IID, + LIST_GROUP_WORK_ITEMS, + LIST_PROJECT_WORK_ITEMS, + UPDATE_WORK_ITEM, +} from '../../../../src/graphql/workItems'; + +const mockRequest = jest.fn(); +jest.mock('../../../../src/services/ConnectionManager', () => ({ + ConnectionManager: { getInstance: () => ({ getClient: () => ({ request: mockRequest }) }) }, +})); + +// Schema capabilities of the simulated instance: "Type.field" keys it lacks. +const missing = new Set(); +jest.mock('../../../../src/entities/instance-version', () => ({ + graphqlSupports: (type: string, field?: string) => !missing.has(`${type}.${field}`), +})); + +jest.mock('../../../../src/utils/workItemTypes', () => ({ + getWorkItemTypes: () => + Promise.resolve([{ id: 'gid://gitlab/WorkItems::Type/2', name: 'Issue' }]), +})); + +jest.mock('../../../../src/services/WidgetAvailability', () => ({ + WidgetAvailability: { validateWidgetParams: () => null }, +})); + +const browse = () => workitemsToolRegistry.get('browse_work_items')!; +const manage = () => workitemsToolRegistry.get('manage_work_item')!; +const item = (iid: string) => ({ + id: `gid://gitlab/WorkItem/${iid}`, + iid, + title: 't', + state: 'OPEN', +}); +const connection = (...iids: string[]) => ({ + nodes: iids.map(item), + pageInfo: { hasNextPage: false, endCursor: null }, +}); + +beforeEach(() => { + mockRequest.mockReset(); + missing.clear(); +}); + +describe('browse_work_items list', () => { + it('uses the namespace query when the instance has it', async () => { + mockRequest.mockResolvedValueOnce({ namespace: { workItems: connection('1') } }); + await browse().handler({ action: 'list', namespace: 'grp/proj' }); + expect(mockRequest.mock.calls[0][0]).toBe(GET_NAMESPACE_WORK_ITEMS); + }); + + it('falls back to the project listing without Namespace.workItems', async () => { + missing.add('Namespace.workItems'); + mockRequest.mockResolvedValueOnce({ project: { workItems: connection('7') } }); + + const result = (await browse().handler({ action: 'list', namespace: 'grp/proj' })) as { + items: Array<{ iid: string }>; + }; + + expect(mockRequest.mock.calls[0][0]).toBe(LIST_PROJECT_WORK_ITEMS); + expect(result.items.map((i) => i.iid)).toEqual(['7']); + }); + + it('falls back to the group listing when the path is not a project', async () => { + missing.add('Namespace.workItems'); + mockRequest + .mockResolvedValueOnce({ project: null }) + .mockResolvedValueOnce({ group: { workItems: connection('3') } }); + + await browse().handler({ action: 'list', namespace: 'grp' }); + + expect(mockRequest.mock.calls.map((c) => c[0])).toEqual([ + LIST_PROJECT_WORK_ITEMS, + LIST_GROUP_WORK_ITEMS, + ]); + }); + + it('explains when group work items cannot be listed on the instance', async () => { + missing.add('Namespace.workItems'); + missing.add('Group.workItems'); + mockRequest.mockResolvedValueOnce({ project: null }); + + await expect(browse().handler({ action: 'list', namespace: 'grp' })).rejects.toThrow( + 'cannot list group-level work items', + ); + }); +}); + +describe('browse_work_items get by IID', () => { + it('falls back to the project listing filtered by IID', async () => { + missing.add('Namespace.workItem'); + mockRequest.mockResolvedValueOnce({ project: { workItems: connection('5') } }); + + const result = (await browse().handler({ + action: 'get', + namespace: 'grp/proj', + iid: '5', + })) as { iid: string }; + + expect(mockRequest.mock.calls[0][0]).toBe(GET_PROJECT_WORK_ITEM_BY_IID); + expect(result.iid).toBe('5'); + }); + + it('tries the group when the path is not a project, and reports not found', async () => { + missing.add('Namespace.workItem'); + mockRequest + .mockResolvedValueOnce({ project: null }) + .mockResolvedValueOnce({ group: { workItems: { nodes: [] } } }); + + await expect(browse().handler({ action: 'get', namespace: 'grp', iid: '9' })).rejects.toThrow( + 'Work item with IID "9" not found in namespace "grp"', + ); + expect(mockRequest.mock.calls[1][0]).toBe(GET_GROUP_WORK_ITEM_BY_IID); + }); +}); + +describe('manage_work_item create on an older create input', () => { + it('applies widgets the create input lacks through one follow-up update', async () => { + missing.add('WorkItemCreateInput.assigneesWidget'); + missing.add('WorkItemCreateInput.labelsWidget'); + mockRequest + .mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }) + .mockResolvedValueOnce({ workItemUpdate: { workItem: item('1'), errors: [] } }); + + await manage().handler({ + action: 'create', + namespace: 'grp/proj', + title: 't', + workItemType: 'Issue', + assigneeIds: ['4'], + labelIds: ['8'], + milestoneId: '2', + }); + + const createInput = mockRequest.mock.calls[0][1].input; + expect(createInput.assigneesWidget).toBeUndefined(); + expect(createInput.labelsWidget).toBeUndefined(); + // Accepted on create: stays in the create call. + expect(createInput.milestoneWidget).toBeDefined(); + + expect(mockRequest.mock.calls[1][0]).toBe(UPDATE_WORK_ITEM); + expect(mockRequest.mock.calls[1][1].input).toEqual({ + id: 'gid://gitlab/WorkItem/1', + assigneesWidget: { assigneeIds: ['gid://gitlab/User/4'] }, + // Same label GIDs create would have sent, as an add on the fresh item. + labelsWidget: { addLabelIds: ['gid://gitlab/ProjectLabel/8'] }, + }); + }); + + it('keeps the created item and names the deferred properties when the update fails', async () => { + missing.add('WorkItemCreateInput.assigneesWidget'); + mockRequest + .mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }) + .mockResolvedValueOnce({ workItemUpdate: { workItem: null, errors: ['denied'] } }); + + const result = (await manage().handler({ + action: 'create', + namespace: 'grp/proj', + title: 't', + workItemType: 'Issue', + assigneeIds: ['4'], + })) as { _warning: { failedProperties: Record } }; + + expect(result._warning.failedProperties.assigneeIds.error).toBe('denied'); + }); + + it('sends a single create when the instance accepts every widget', async () => { + mockRequest.mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }); + + await manage().handler({ + action: 'create', + namespace: 'grp/proj', + title: 't', + workItemType: 'Issue', + assigneeIds: ['4'], + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('manage_work_item update on an older update input', () => { + it('names the widget the instance cannot update instead of sending the mutation', async () => { + missing.add('WorkItemUpdateInput.colorWidget'); + + await expect(manage().handler({ action: 'update', id: '1', color: '#ff0000' })).rejects.toThrow( + 'This GitLab instance cannot update color on work items', + ); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts new file mode 100644 index 000000000..c960993ac --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts @@ -0,0 +1,108 @@ +/** + * prepareDocument adapts a query to the connected instance's schema so one + * document serves every supported GitLab version: unknown fragment types and + * missing @optional fields are dropped, essential fields are kept so GitLab can + * report them, and the client-only @optional directive never reaches GitLab. + */ + +import { gql } from 'graphql-tag'; +import { print } from 'graphql'; +import { prepareDocument } from '../../../src/graphql/prepare-document'; +import type { IndexedField, SchemaFieldIndex } from '../../../src/services/SchemaIntrospector'; + +const field = (type: string, args: string[] = []): IndexedField => ({ type, args: new Set(args) }); + +// An "old" schema: Tag has no mediaType, the Color widget type does not exist. +const oldSchema: SchemaFieldIndex = new Map([ + ['Query', new Map([['repo', field('Repo', ['id'])]])], + [ + 'Repo', + new Map([ + ['name', field('String')], + ['tags', field('Tag')], + ['widgets', field('Widget')], + ]), + ], + ['Tag', new Map([['name', field('String')]])], + ['Widget', new Map([['type', field('String')]])], + ['WidgetLabels', new Map([['labels', field('String')]])], +]); + +const QUERY = gql` + query Q($id: ID!, $since: String) { + repo(id: $id) { + name + tags { + name + mediaType @optional + publishedAt(since: $since) @optional + } + widgets { + type + ... on WidgetLabels { + labels + } + ... on WidgetColor { + color + } + } + } + } +`; + +describe('prepareDocument', () => { + it('drops missing @optional fields and fragments on unknown types', () => { + const printed = print(prepareDocument(QUERY, oldSchema).document); + + expect(printed).not.toContain('mediaType'); + expect(printed).not.toContain('publishedAt'); + expect(printed).not.toContain('WidgetColor'); + // Known selections survive untouched. + expect(printed).toContain('... on WidgetLabels'); + expect(printed).toContain('name'); + }); + + it('removes variable definitions only the dropped selections used', () => { + // GitLab rejects a declared-but-unused variable. + const prepared = prepareDocument(QUERY, oldSchema); + expect(print(prepared.document)).not.toContain('$since'); + expect([...prepared.variableNames]).toEqual(['id']); + }); + + it('keeps an essential missing field so GitLab reports it', () => { + // Silently dropping it would turn an unsupported query into an empty answer. + const doc = gql` + query { + repo(id: 1) { + missingEssential + } + } + `; + expect(print(prepareDocument(doc, oldSchema).document)).toContain('missingEssential'); + }); + + it('keeps present @optional fields and strips the directive', () => { + const newSchema: SchemaFieldIndex = new Map([ + ...oldSchema, + ['Tag', new Map([...oldSchema.get('Tag')!, ['mediaType', field('String')]])], + ]); + const printed = print(prepareDocument(QUERY, newSchema).document); + + expect(printed).toContain('mediaType'); + expect(printed).not.toContain('@optional'); + }); + + it('only strips the directive when the schema is unknown', () => { + const prepared = prepareDocument(QUERY, undefined); + const printed = print(prepared.document); + + expect(printed).toContain('mediaType'); + expect(printed).toContain('WidgetColor'); + expect(printed).not.toContain('@optional'); + expect(new Set(prepared.variableNames)).toEqual(new Set(['id', 'since'])); + }); + + it('returns the same prepared document for the same schema', () => { + expect(prepareDocument(QUERY, oldSchema)).toBe(prepareDocument(QUERY, oldSchema)); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts index 014610981..69144df3c 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts @@ -110,6 +110,7 @@ describe('ConnectionManager Enhanced Tests', () => { request: jest.fn(), endpoint: 'https://test-gitlab.com/api/graphql', setEndpoint: jest.fn(), + setSchemaIndexProvider: jest.fn(), } as unknown as jest.Mocked; MockedGraphQLClient.mockImplementation(() => mockClient); @@ -1040,6 +1041,7 @@ describe('ConnectionManager Enhanced Tests', () => { request: jest.fn(), endpoint: 'https://cached-gitlab.example.com/api/graphql', setEndpoint: jest.fn(), + setSchemaIndexProvider: jest.fn(), })), })); jest.doMock('../../../src/services/GitLabVersionDetector', () => ({ diff --git a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts index 3be2c1973..58732cf19 100644 --- a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts @@ -14,6 +14,8 @@ import { getRestrictedParameters, getUnmetReason, getHighestTier, + effectiveMinVersion, + MIN_SUPPORTED_VERSION, type CapabilityGate, } from '../../../src/services/InstanceCapabilities'; import { ToolRequirements } from '../../../src/types'; @@ -70,8 +72,19 @@ describe('meetsRequirement', () => { expect(meetsRequirement({ tier: 'premium', minVersion: '8.0' }, ultimate17)).toBe(true); }); - it('treats missing tier/version as the free/8.0 default', () => { + it('treats missing tier/version as free at the supported floor', () => { expect(meetsRequirement({}, free17)).toBe(true); + expect(meetsRequirement({}, { version: '15.11.0', tier: 'ultimate' })).toBe(false); + }); + + it('never lets a declared minVersion lower the supported floor', () => { + // A stale sub-floor declaration must not re-admit an unsupported instance. + expect(meetsRequirement({ minVersion: '8.0' }, { version: '15.11.0', tier: 'free' })).toBe( + false, + ); + expect(effectiveMinVersion({ minVersion: '8.0' })).toBe(MIN_SUPPORTED_VERSION); + expect(effectiveMinVersion({ minVersion: '17.2' })).toBe('17.2'); + expect(effectiveMinVersion(undefined)).toBe(MIN_SUPPORTED_VERSION); }); it('fails an admin requirement only when admin-mode elevation is known inactive', () => { @@ -115,9 +128,9 @@ describe('isToolAvailable', () => { expect(isToolAvailable(undefined, unknown)).toBe(true); }); - it('applies a conservative >= 15.0 gate to tools without declared requirements', () => { - expect(isToolAvailable(undefined, { version: '14.9.0', tier: 'ultimate' })).toBe(false); - expect(isToolAvailable(undefined, { version: '15.0.0', tier: 'free' })).toBe(true); + it('applies the supported version floor to tools without declared requirements', () => { + expect(isToolAvailable(undefined, { version: '15.11.0', tier: 'ultimate' })).toBe(false); + expect(isToolAvailable(undefined, { version: '16.0.0', tier: 'free' })).toBe(true); }); }); @@ -186,8 +199,8 @@ describe('getUnmetReason', () => { expect(getUnmetReason(reqs, { version: 'unknown', tier: 'free' }, 'approve')).toBeNull(); }); - it('gates an unannotated tool conservatively and reports the reason', () => { - expect(getUnmetReason(undefined, { version: '14.0.0', tier: 'ultimate' })).toContain('15.0+'); + it('gates an unannotated tool at the supported floor and reports the reason', () => { + expect(getUnmetReason(undefined, { version: '15.11.0', tier: 'ultimate' })).toContain('16.0+'); expect(getUnmetReason(undefined, { version: '16.0.0', tier: 'free' })).toBeNull(); }); }); @@ -209,26 +222,30 @@ describe('getHighestTier', () => { describe('shipped tool requirements (real data)', () => { // These assert that the requirements migrated onto real tool definitions are // correct, end-to-end — a regression here means a tool would be mis-gated. - it('marks browse_iterations as premium 13.1', () => { + it('marks browse_iterations as premium at the supported floor', () => { const { iterationsToolRegistry } = require('../../../src/entities/iterations/registry'); const req = iterationsToolRegistry.get('browse_iterations')?.requirements; - expect(req?.default).toEqual({ - tier: 'premium', - minVersion: '13.1', - notes: 'Iterations/Sprints', - }); + expect(req?.default).toEqual({ tier: 'premium', notes: 'Iterations/Sprints' }); }); - it('gates browse_work_items at free 15.0 and manage_work_item params by tier', () => { + it('keeps work items available from the floor, gating only the link mutations', () => { + // Queries adapt to the instance schema and fall back to project/group + // queries, so only the linked-items mutations (no older equivalent) are gated. const { workitemsToolRegistry } = require('../../../src/entities/workitems/registry'); - expect(workitemsToolRegistry.get('browse_work_items')?.requirements?.default).toEqual({ - tier: 'free', - minVersion: '15.0', - }); - const params = workitemsToolRegistry.get('manage_work_item')?.requirements?.parameters; - expect(params?.weight?.tier).toBe('premium'); - expect(params?.iterationId?.tier).toBe('premium'); - expect(params?.healthStatus?.tier).toBe('ultimate'); + const browse = workitemsToolRegistry.get('browse_work_items')?.requirements; + const floor = { version: '16.0.0', tier: 'ultimate' as const }; + expect(isToolAvailable(browse, floor, 'list')).toBe(true); + expect(isToolAvailable(browse, floor, 'get')).toBe(true); + + const manage = workitemsToolRegistry.get('manage_work_item')?.requirements; + const at = (version: string) => ({ version, tier: 'ultimate' as const }); + expect(isToolAvailable(manage, at('16.0.0'), 'create')).toBe(true); + expect(isToolAvailable(manage, at('16.0.0'), 'update')).toBe(true); + expect(isToolAvailable(manage, at('16.3.0'), 'add_link')).toBe(false); + expect(isToolAvailable(manage, at('16.4.0'), 'remove_link')).toBe(true); + expect(manage?.parameters?.weight?.tier).toBe('premium'); + expect(manage?.parameters?.iterationId?.tier).toBe('premium'); + expect(manage?.parameters?.healthStatus?.tier).toBe('ultimate'); }); it('keeps the MR approvals action premium while the tool default stays free', () => { @@ -238,14 +255,38 @@ describe('shipped tool requirements (real data)', () => { expect(req?.actions?.approvals?.tier).toBe('premium'); }); - it('marks the milestones burndown action premium 12.0', () => { + it.each([ + // [registry module, tool, action or undefined, parameter or undefined, first version] + // Only capabilities GitLab cannot provide on older instances are gated; the + // rest are emulated in the handlers. + ['files', 'browse_files', 'download_attachment', undefined, '17.4'], + ['webhooks', 'manage_webhook', 'test', undefined, '16.11'], + ['webhooks', 'manage_webhook', undefined, 'feature_flag_events', '17.5'], + ['webhooks', 'manage_webhook', undefined, 'project_events', '18.2'], + ['pipelines', 'manage_pipeline', undefined, 'inputs', '17.10'], + ['workitems', 'manage_work_item', 'add_link', undefined, '16.4'], + ])('%s: %s %s %s requires GitLab %s', (module, toolName, action, param, version) => { + // Versions verified against GitLab sources at the release tags (see AGENTS.md). + const registry: Map = Object.values( + require(`../../../src/entities/${module}/registry`), + ).find((v) => v instanceof Map) as Map; + const reqs = registry.get(toolName)!.requirements; + const [major, minor] = version.split('.').map(Number); + const before = { version: `${major}.${minor - 1}.0`, tier: 'ultimate' as const }; + const at = { version: `${version}.0`, tier: 'ultimate' as const }; + if (param) { + expect(getRestrictedParameters(reqs, before)).toContain(param); + expect(getRestrictedParameters(reqs, at)).not.toContain(param); + } else { + expect(isToolAvailable(reqs, before, action)).toBe(false); + expect(isToolAvailable(reqs, at, action)).toBe(true); + } + }); + + it('marks the milestones burndown action premium', () => { const { milestonesToolRegistry } = require('../../../src/entities/milestones/registry'); const req = milestonesToolRegistry.get('browse_milestones')?.requirements; - expect(req?.actions?.burndown).toEqual({ - tier: 'premium', - minVersion: '12.0', - notes: 'Burndown charts', - }); + expect(req?.actions?.burndown).toEqual({ tier: 'premium', notes: 'Burndown charts' }); }); it('tier-gates the premium/ultimate group attributes on manage_namespace', () => { @@ -263,14 +304,17 @@ describe('shipped tool requirements (real data)', () => { expect(schemaJson).toContain(name); } - // Free strips all gated params; ultimate strips none. Order is irrelevant, - // so compare as sets. + // Free strips all gated params; ultimate on 17.0 strips only the version-gated + // ones (allowed email domains 17.4, automatic Duo review 18.7). Order is + // irrelevant, so compare as sets. const free = { version: '17.0.0', tier: 'free' as const }; const ultimate = { version: '17.0.0', tier: 'ultimate' as const }; expect(new Set(getRestrictedParameters(tool.requirements, free))).toEqual( new Set(Object.keys(params)), ); - expect(getRestrictedParameters(tool.requirements, ultimate)).toEqual([]); + expect(new Set(getRestrictedParameters(tool.requirements, ultimate))).toEqual( + new Set(['allowed_email_domains_list', 'auto_duo_code_review_enabled']), + ); }); it('tier-gates the premium/ultimate project attributes on manage_project', () => { @@ -287,10 +331,20 @@ describe('shipped tool requirements (real data)', () => { expect(schemaJson).toContain(name); } - // Premium instance keeps premium params, still strips the ultimate ones. + // Premium instance keeps premium params, still strips the ultimate ones and + // every GitLab Duo setting, all of which postdate 17.0. const premium = { version: '17.0.0', tier: 'premium' as const }; expect(new Set(getRestrictedParameters(tool.requirements, premium))).toEqual( - new Set(['only_allow_merge_if_all_status_checks_passed', 'requirements_access_level']), + new Set([ + 'only_allow_merge_if_all_status_checks_passed', + 'requirements_access_level', + 'auto_duo_code_review_enabled', + 'duo_remote_flows_enabled', + 'duo_sast_fp_detection_enabled', + 'duo_sast_vr_workflow_enabled', + 'duo_secret_detection_fp_enabled', + 'duo_dependency_bump_breaking_changes_enabled', + ]), ); }); }); diff --git a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts index 78a5a8f2a..2d6411078 100644 --- a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts @@ -291,11 +291,12 @@ describe('WidgetAvailability', () => { describe('widget requirements', () => { it('should return widget requirement correctly', () => { + // Widgets present at the supported floor declare no minVersion. const assigneesReq = WidgetAvailability.getWidgetRequirement(WorkItemWidgetTypes.ASSIGNEES); - expect(assigneesReq).toEqual({ tier: 'free', minVersion: '15.0' }); + expect(assigneesReq).toEqual({ tier: 'free' }); const weightReq = WidgetAvailability.getWidgetRequirement(WorkItemWidgetTypes.WEIGHT); - expect(weightReq).toEqual({ tier: 'premium', minVersion: '15.0' }); + expect(weightReq).toEqual({ tier: 'premium' }); const customFieldsReq = WidgetAvailability.getWidgetRequirement( WorkItemWidgetTypes.CUSTOM_FIELDS, @@ -305,7 +306,7 @@ describe('WidgetAvailability', () => { const verificationStatusReq = WidgetAvailability.getWidgetRequirement( WorkItemWidgetTypes.VERIFICATION_STATUS, ); - expect(verificationStatusReq).toEqual({ tier: 'ultimate', minVersion: '13.1' }); + expect(verificationStatusReq).toEqual({ tier: 'ultimate' }); }); it('should return undefined for unknown widget', () => { @@ -428,7 +429,8 @@ describe('WidgetAvailability', () => { }); it('should detect version-restricted widget parameters', () => { - // Old GitLab version (14.0) should fail for ASSIGNEES (requires 15.0+) + // An instance below the supported floor (16.0) fails even for ASSIGNEES, + // which declares no minVersion of its own. const oldVersionInfo = { ...mockInstanceInfoFree, version: '14.0.0', @@ -442,7 +444,7 @@ describe('WidgetAvailability', () => { expect(result).not.toBeNull(); expect(result!.parameter).toBe('assigneeIds'); expect(result!.widget).toBe('ASSIGNEES'); - expect(result!.requiredVersion).toBe('15.0'); + expect(result!.requiredVersion).toBe('16.0'); expect(result!.detectedVersion).toBe('14.0.0'); }); diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index b09e9e8b7..d775b8a72 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -3,6 +3,7 @@ import { transliterateText, hasNonLatin, smartUserSearch, + fetchUsers, type QueryPattern, } from '../../../src/utils/smart-user-search'; import { enhancedFetch } from '../../../src/utils/fetch'; @@ -12,6 +13,13 @@ jest.mock('../../../src/utils/fetch', () => ({ enhancedFetch: jest.fn(), })); +// Whether the simulated instance has the native user-type filters (GitLab 17.3). +// A plain variable, not a jest.fn, so resetAllMocks below cannot clear it. +let nativeUserFilters = true; +jest.mock('../../../src/entities/instance-version', () => ({ + instanceAtLeast: () => nativeUserFilters, +})); + const mockEnhancedFetch = enhancedFetch as jest.MockedFunction; // Mock environment variables @@ -33,6 +41,62 @@ beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); mockEnhancedFetch.mockReset(); + nativeUserFilters = true; +}); + +describe('fetchUsers user-type filters', () => { + const users = [ + { id: 1, username: 'alice', state: 'active', bot: false }, + { id: 2, username: 'alert-bot', state: 'active', bot: true }, + { id: 3, username: 'bob', state: 'blocked', bot: false }, + ]; + const respond = (body: unknown) => + mockEnhancedFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response); + const sentUrl = () => new URL(mockEnhancedFetch.mock.calls[0][0]); + + it('passes the filters to GitLab when it supports them (17.3+)', async () => { + respond([users[0]]); + await fetchUsers({ humans: true, exclude_active: false }); + expect(sentUrl().searchParams.get('humans')).toBe('true'); + }); + + it('emulates humans on older instances: project bots server-side, other bots client-side', async () => { + nativeUserFilters = false; + respond(users); + + const result = (await fetchUsers({ humans: true })) as Array<{ id: number }>; + + expect(sentUrl().searchParams.get('humans')).toBeNull(); + expect(sentUrl().searchParams.get('without_project_bots')).toBe('true'); + expect(result.map((u) => u.id)).toEqual([1, 3]); + }); + + it('emulates exclude_active on each user state', async () => { + nativeUserFilters = false; + respond(users); + const result = (await fetchUsers({ exclude_active: true })) as Array<{ id: number }>; + expect(result.map((u) => u.id)).toEqual([3]); + }); + + it('emulates exclude_humans when the response carries the bot flag', async () => { + nativeUserFilters = false; + respond(users); + const result = (await fetchUsers({ exclude_humans: true })) as Array<{ id: number }>; + expect(result.map((u) => u.id)).toEqual([2]); + }); + + it('refuses exclude_humans when the response lacks the bot flag (non-admin, older GitLab)', async () => { + // Without the flag, bots and humans are indistinguishable; returning either + // set would be a guess. + nativeUserFilters = false; + respond([{ id: 1, username: 'alice', state: 'active' }]); + await expect(fetchUsers({ exclude_humans: true })).rejects.toThrow( + 'Filtering to bot users needs GitLab 17.3+', + ); + }); }); describe('smart-user-search utilities', () => { From d12880a41787481a4d6c6651d2d7509bf32396be Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:05:57 +0300 Subject: [PATCH 02/25] ci(release): publish a single GitHub release per version The version-locked db package got its own GitHub release that only repeated the core one: the core release already carries both MCPB bundles. Delete the db release object right after release-please creates it and mark the core release as latest. The db tag stays, since release-please finds the previous db release by it. Also quote the summary and mcp-publisher paths flagged by shellcheck. --- .github/workflows/release-please.yml | 60 ++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 72851463a..b4e6ec839 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -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 @@ -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 @@ -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" From acd5591a64290e41083baecff405e83712eb3685 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:17:46 +0300 Subject: [PATCH 03/25] fix(graphql): keep queries valid when every optional field is dropped When all sub-selections of a kept field, or of the whole operation, were @optional fields missing from the instance schema, the original selection was sent unchanged: the client-only @optional directive and the missing fields reached GitLab, which rejected the query. Such a selection now becomes { __typename }, valid on every type. --- .../src/graphql/prepare-document.ts | 15 +++- .../unit/graphql/prepare-document.test.ts | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/gitlab-mcp/src/graphql/prepare-document.ts b/packages/gitlab-mcp/src/graphql/prepare-document.ts index 9d505aefe..851f0bb21 100644 --- a/packages/gitlab-mcp/src/graphql/prepare-document.ts +++ b/packages/gitlab-mcp/src/graphql/prepare-document.ts @@ -28,6 +28,16 @@ const isOptional = (directives?: readonly DirectiveNode[]): boolean => const stripOptional = (directives?: readonly DirectiveNode[]): DirectiveNode[] | undefined => directives?.filter((d) => d.name.value !== OPTIONAL_DIRECTIVE); +/** + * Selection for a kept field (or operation) whose every sub-selection was + * dropped: a composite type needs at least one field, and __typename exists on + * every type. + */ +const typenameOnly = (set: SelectionSetNode): SelectionSetNode => ({ + ...set, + selections: [{ kind: Kind.FIELD, name: { kind: Kind.NAME, value: '__typename' } }], +}); + /** * Drop what the instance cannot answer. An inline fragment on a type the schema * does not declare can never match, so it always goes. A missing field goes only @@ -53,7 +63,7 @@ function pruneSelectionSet( if (selectionSet) { const pruned = pruneSelectionSet(selectionSet, field?.type, index); if (!pruned && optional) continue; - selectionSet = pruned ?? selectionSet; + selectionSet = pruned ?? typenameOnly(selectionSet); } selections.push({ ...selection, @@ -89,7 +99,8 @@ function prepare(document: DocumentNode, index: SchemaFieldIndex | undefined): P if (definition.kind !== Kind.OPERATION_DEFINITION) return definition; const root = definition.operation === 'mutation' ? 'Mutation' : 'Query'; const selectionSet = - pruneSelectionSet(definition.selectionSet, root, index) ?? definition.selectionSet; + pruneSelectionSet(definition.selectionSet, root, index) ?? + typenameOnly(definition.selectionSet); return { ...definition, selectionSet }; }), }; diff --git a/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts index c960993ac..76933a72c 100644 --- a/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts +++ b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts @@ -102,6 +102,84 @@ describe('prepareDocument', () => { expect(new Set(prepared.variableNames)).toEqual(new Set(['id', 'since'])); }); + it('keeps a field whose optional children are all missing as a valid selection', () => { + // Keeping the original selection would send the client-only @optional + // directive and the missing fields, so GitLab would reject the whole query. + const doc = gql` + query { + repo(id: 1) { + tags { + mediaType @optional + } + } + gone @optional + } + `; + const printed = print(prepareDocument(doc, oldSchema).document); + + expect(printed).not.toContain('@optional'); + expect(printed).not.toContain('mediaType'); + expect(printed).not.toContain('gone'); + expect(printed).toMatch(/tags\s*\{\s*__typename\s*\}/); + }); + + it('answers with __typename when every root selection is dropped', () => { + const doc = gql` + query { + gone @optional + } + `; + const printed = print(prepareDocument(doc, oldSchema).document); + + expect(printed).not.toContain('@optional'); + expect(printed).toMatch(/\{\s*__typename\s*\}/); + }); + + it('drops an optional field whose children are all missing', () => { + const doc = gql` + query { + repo(id: 1) { + name + tags @optional { + mediaType @optional + } + } + } + `; + expect(print(prepareDocument(doc, oldSchema).document)).not.toContain('tags'); + }); + + it('prunes inside typeless and named fragments, and drops emptied ones', () => { + const doc = gql` + query { + repo(id: 1) { + ... { + name + mediaType @optional + } + ... on Repo { + mediaType @optional + } + ...RepoTags + } + } + fragment RepoTags on Repo { + tags { + name + } + } + `; + const printed = print(prepareDocument(doc, oldSchema).document); + + expect(printed).not.toContain('mediaType'); + expect(printed).not.toContain('... on Repo'); + // The typeless fragment inherits the enclosing type and keeps its known field. + expect(printed).toMatch(/\.\.\.\s*\{\s*name\s*\}/); + // Named fragments and their spreads pass through untouched. + expect(printed).toContain('...RepoTags'); + expect(printed).toContain('fragment RepoTags on Repo'); + }); + it('returns the same prepared document for the same schema', () => { expect(prepareDocument(QUERY, oldSchema)).toBe(prepareDocument(QUERY, oldSchema)); }); From 0598c112e90c9ec56dd8ff7a6ad84f48d4634066 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:17:56 +0300 Subject: [PATCH 04/25] refactor(workitems): type the widget-to-parameter map exhaustively The map naming the tool parameter behind each update widget is now keyed by the widget fields of WorkItemUpdateInput, so a new widget without a name fails to compile instead of reaching users as a raw widget key. Adds tests for the remaining project/group fallbacks and for deferring the description on create. --- .../src/entities/workitems/registry.ts | 24 +++---- .../workitems/schema-fallbacks.test.ts | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/workitems/registry.ts b/packages/gitlab-mcp/src/entities/workitems/registry.ts index e50e19e5b..6a75d82b1 100644 --- a/packages/gitlab-mcp/src/entities/workitems/registry.ts +++ b/packages/gitlab-mcp/src/entities/workitems/registry.ts @@ -40,8 +40,10 @@ const SAME_SHAPE_CREATE_WIDGETS = [ 'colorWidget', ] as const; -/** Tool parameter a deferred widget carries, named in failure reports. */ -const DEFERRED_WIDGET_PROPERTY: Readonly> = { +type UpdateWidgetKey = Extract; + +/** Tool parameter each update widget carries, named in reports; exhaustive by type. */ +const WIDGET_PROPERTY: Readonly> = { timeTrackingWidget: 'timeEstimate', descriptionWidget: 'description', labelsWidget: 'labelIds', @@ -58,10 +60,8 @@ const DEFERRED_WIDGET_PROPERTY: Readonly> = { }; /** A single-field widget input reports its value; a multi-field one reports the object. */ -const unwrapWidget = (value: unknown): unknown => - value !== null && typeof value === 'object' && Object.keys(value).length === 1 - ? Object.values(value)[0] - : value; +const unwrapWidget = (value: object): unknown => + Object.keys(value).length === 1 ? Object.values(value)[0] : value; /** Whether this instance's workItemCreate input accepts the field. */ const createSupports = (field: string): boolean => graphqlSupports('WorkItemCreateInput', field); @@ -703,8 +703,8 @@ export const workitemsToolRegistry: ToolRegistry = new Map [ - DEFERRED_WIDGET_PROPERTY[widget] ?? widget, - { requestedValue: unwrapWidget(requestedValue), error }, + WIDGET_PROPERTY[widget as UpdateWidgetKey], + { requestedValue: unwrapWidget(requestedValue as object), error }, ]), ), }, @@ -996,13 +996,13 @@ export const workitemsToolRegistry: ToolRegistry = new Map key.endsWith('Widget') && !graphqlSupports('WorkItemUpdateInput', key), - ); + const unsupported = (Object.keys(updateInput) as Array) + .filter((key): key is UpdateWidgetKey => key.endsWith('Widget')) + .filter((key) => !graphqlSupports('WorkItemUpdateInput', key)); if (unsupported.length > 0) { throw new Error( `This GitLab instance cannot update ${unsupported - .map((key) => DEFERRED_WIDGET_PROPERTY[key] ?? key) + .map((key) => WIDGET_PROPERTY[key]) .join(', ')} on work items`, ); } diff --git a/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts index 0c43a265e..18af23c58 100644 --- a/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts @@ -86,6 +86,24 @@ describe('browse_work_items list', () => { ]); }); + it('returns an empty page when the namespace does not exist', async () => { + mockRequest.mockResolvedValueOnce({ namespace: null }); + const result = (await browse().handler({ action: 'list', namespace: 'gone', first: 5 })) as { + items: unknown[]; + }; + expect(mockRequest.mock.calls[0][1]).toMatchObject({ first: 5 }); + expect(result.items).toEqual([]); + }); + + it('returns an empty page when the fallback finds neither project nor group', async () => { + missing.add('Namespace.workItems'); + mockRequest.mockResolvedValueOnce({ project: null }).mockResolvedValueOnce({ group: null }); + const result = (await browse().handler({ action: 'list', namespace: 'gone' })) as { + items: unknown[]; + }; + expect(result.items).toEqual([]); + }); + it('explains when group work items cannot be listed on the instance', async () => { missing.add('Namespace.workItems'); missing.add('Group.workItems'); @@ -123,6 +141,27 @@ describe('browse_work_items get by IID', () => { ); expect(mockRequest.mock.calls[1][0]).toBe(GET_GROUP_WORK_ITEM_BY_IID); }); + + it('reports not found when the project has no item with the IID', async () => { + missing.add('Namespace.workItem'); + mockRequest.mockResolvedValueOnce({ project: { workItems: { nodes: [] } } }); + + await expect( + browse().handler({ action: 'get', namespace: 'grp/proj', iid: '9' }), + ).rejects.toThrow('Work item with IID "9" not found in namespace "grp/proj"'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('skips the group lookup when the instance has no group work items', async () => { + missing.add('Namespace.workItem'); + missing.add('Group.workItems'); + mockRequest.mockResolvedValueOnce({ project: null }); + + await expect(browse().handler({ action: 'get', namespace: 'grp', iid: '9' })).rejects.toThrow( + 'not found', + ); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); }); describe('manage_work_item create on an older create input', () => { @@ -175,6 +214,37 @@ describe('manage_work_item create on an older create input', () => { expect(result._warning.failedProperties.assigneeIds.error).toBe('denied'); }); + it('defers the description and reports multi-field widgets as a whole', async () => { + missing.add('WorkItemCreateInput.description'); + missing.add('WorkItemCreateInput.startAndDueDateWidget'); + mockRequest + .mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }) + .mockResolvedValueOnce({ workItemUpdate: { workItem: null, errors: ['denied'] } }); + + const result = (await manage().handler({ + action: 'create', + namespace: 'grp/proj', + title: 't', + workItemType: 'Issue', + description: 'body', + startDate: '2026-01-01', + dueDate: '2026-02-01', + })) as { _warning: { failedProperties: Record } }; + + const createInput = mockRequest.mock.calls[0][1].input; + expect(createInput.description).toBeUndefined(); + expect(createInput.startAndDueDateWidget).toBeUndefined(); + expect(mockRequest.mock.calls[1][1].input.descriptionWidget).toEqual({ description: 'body' }); + + const failed = result._warning.failedProperties; + // A single-field widget reports its value, a multi-field one the whole input. + expect(failed.description.requestedValue).toBe('body'); + expect(failed.dates.requestedValue).toMatchObject({ + startDate: '2026-01-01', + dueDate: '2026-02-01', + }); + }); + it('sends a single create when the instance accepts every widget', async () => { mockRequest.mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }); From 70c608a871c29d2dc3d91a2332351d8467d774d6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:18:02 +0300 Subject: [PATCH 05/25] test: cover schema probes, schema index and version fallbacks --- .../unit/entities/instance-version.test.ts | 80 +++++++++++++++++++ .../unit/entities/runners/registry.test.ts | 8 ++ .../tests/unit/graphql/client.test.ts | 33 ++++++++ .../unit/services/SchemaIntrospector.test.ts | 41 ++++++++++ .../unit/utils/smart-user-search.test.ts | 13 +++ .../tests/unit/utils/workItemTypes.test.ts | 42 ++++++++++ 6 files changed, 217 insertions(+) create mode 100644 packages/gitlab-mcp/tests/unit/entities/instance-version.test.ts diff --git a/packages/gitlab-mcp/tests/unit/entities/instance-version.test.ts b/packages/gitlab-mcp/tests/unit/entities/instance-version.test.ts new file mode 100644 index 000000000..8408b159e --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/instance-version.test.ts @@ -0,0 +1,80 @@ +/** + * Instance capability probes used by handlers to choose a native or fallback + * path. graphqlSupports reads the introspected schema; it must fail open (native + * path) only when the schema is unknown, never when a type or field is absent. + */ + +import { + assertInstanceAtLeast, + graphqlSupports, + instanceAtLeast, +} from '../../../src/entities/instance-version'; +import type { IndexedField, SchemaFieldIndex } from '../../../src/services/SchemaIntrospector'; + +const field = (args: string[] = []): IndexedField => ({ type: 'String', args: new Set(args) }); + +let schema: { fieldIndex?: SchemaFieldIndex } | Error = {}; +let instance: { version: string; tier: string } | Error = { version: '17.0.0', tier: 'free' }; + +jest.mock('../../../src/oauth/token-context', () => ({ + getGitLabApiUrlFromContext: () => 'https://gitlab.example.com', +})); +jest.mock('../../../src/services/ConnectionManager', () => ({ + ConnectionManager: { + getInstance: () => ({ + getSchemaInfo: () => { + if (schema instanceof Error) throw schema; + return schema; + }, + getInstanceInfo: () => { + if (instance instanceof Error) throw instance; + return instance; + }, + }), + }, +})); + +beforeEach(() => { + schema = { + fieldIndex: new Map([['Namespace', new Map([['workItems', field(['types'])]])]]), + }; + instance = { version: '17.0.0', tier: 'free' }; +}); + +describe('graphqlSupports', () => { + it('answers from the schema for type, field and argument', () => { + expect(graphqlSupports('Namespace')).toBe(true); + expect(graphqlSupports('Namespace', 'workItems')).toBe(true); + expect(graphqlSupports('Namespace', 'workItems', 'types')).toBe(true); + + expect(graphqlSupports('Group')).toBe(false); + expect(graphqlSupports('Namespace', 'workItemTypes')).toBe(false); + expect(graphqlSupports('Namespace', 'workItems', 'sort')).toBe(false); + }); + + it('fails open when introspection produced no index', () => { + schema = {}; + expect(graphqlSupports('Group', 'workItems')).toBe(true); + }); + + it('fails open when the connection is not initialised', () => { + schema = new Error('not initialised'); + expect(graphqlSupports('Group', 'workItems')).toBe(true); + }); +}); + +describe('instanceAtLeast / assertInstanceAtLeast', () => { + it('compares the detected version', () => { + expect(instanceAtLeast('16.5')).toBe(true); + expect(instanceAtLeast('17.1')).toBe(false); + expect(() => assertInstanceAtLeast('17.1', 'Testing a group webhook')).toThrow( + 'Testing a group webhook requires GitLab 17.1+', + ); + }); + + it('fails open when the version is unknown', () => { + instance = new Error('not initialised'); + expect(instanceAtLeast('99.0')).toBe(true); + expect(() => assertInstanceAtLeast('99.0', 'X')).not.toThrow(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts index 4f7cd7195..ee949515a 100644 --- a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts @@ -141,6 +141,14 @@ describe('runners registry', () => { status: 'online', paused: false, }, + // A runner without a description never matches a search. + { + id: 9, + description: null, + runner_type: 'project_type', + status: null, + paused: false, + }, ]); const res = (await browse().handler({ diff --git a/packages/gitlab-mcp/tests/unit/graphql/client.test.ts b/packages/gitlab-mcp/tests/unit/graphql/client.test.ts index 60ba9ac16..f12a40849 100644 --- a/packages/gitlab-mcp/tests/unit/graphql/client.test.ts +++ b/packages/gitlab-mcp/tests/unit/graphql/client.test.ts @@ -51,6 +51,39 @@ describe('GraphQLClient', () => { }); }); + describe('schema adaptation', () => { + it('sends the document adapted to the provided schema, with only its variables', async () => { + mockEnhancedFetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ data: {} }), + } as unknown as Response); + const field = (type: string) => ({ type, args: new Set() }); + client.setSchemaIndexProvider( + () => + new Map([ + ['Query', new Map([['project', field('Project')]])], + ['Project', new Map([['id', field('ID')]])], + ]), + ); + const doc = gql` + query Q($id: ID!, $since: String) { + project(fullPath: $id) { + id + releasedAt(since: $since) @optional + } + } + `; + + await client.request(doc, { id: 'grp/proj', since: '2026-01-01' }); + + const body = JSON.parse(mockEnhancedFetch.mock.calls[0][1]!.body as string); + expect(body.query).not.toContain('releasedAt'); + expect(body.query).not.toContain('@optional'); + expect(body.variables).toEqual({ id: 'grp/proj' }); + }); + }); + describe('request method', () => { it('should make successful GraphQL request', async () => { const mockData = { diff --git a/packages/gitlab-mcp/tests/unit/services/SchemaIntrospector.test.ts b/packages/gitlab-mcp/tests/unit/services/SchemaIntrospector.test.ts index b96309c4d..34d290259 100644 --- a/packages/gitlab-mcp/tests/unit/services/SchemaIntrospector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/SchemaIntrospector.test.ts @@ -126,6 +126,47 @@ describe('SchemaIntrospector', () => { expect(introspector.getCachedSchema()).toBe(schema); }); + it('indexes fields by their named type and arguments, and input object fields', async () => { + // Documents are adapted to this index, so wrapped types must resolve to the + // named type and mutation inputs must list their fields. + mockGraphQLClient.request.mockResolvedValueOnce({ + __schema: { + types: [ + { + name: 'Namespace', + kind: 'OBJECT', + fields: [ + { + name: 'workItems', + args: [{ name: 'types' }], + type: { + name: null, + kind: 'NON_NULL', + ofType: { name: 'WorkItemConnection', kind: 'OBJECT' }, + }, + }, + ], + }, + { + name: 'WorkItemCreateInput', + kind: 'INPUT_OBJECT', + inputFields: [{ name: 'labelsWidget' }], + }, + { name: 'WorkItemState', kind: 'ENUM', enumValues: [{ name: 'OPEN' }] }, + ], + }, + }); + + const index = (await introspector.introspectSchema()).fieldIndex!; + + const workItems = index.get('Namespace')!.get('workItems')!; + expect(workItems.type).toBe('WorkItemConnection'); + expect([...workItems.args]).toEqual(['types']); + expect(index.get('WorkItemCreateInput')!.has('labelsWidget')).toBe(true); + // Types without fields still exist in the index. + expect(index.get('WorkItemState')!.size).toBe(0); + }); + it('should return cached schema on subsequent calls', async () => { mockGraphQLClient.request.mockResolvedValueOnce(mockIntrospectionResult); diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index d775b8a72..420314197 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -63,6 +63,19 @@ describe('fetchUsers user-type filters', () => { expect(sentUrl().searchParams.get('humans')).toBe('true'); }); + it('does not send parameters left undefined', async () => { + respond([users[0]]); + await fetchUsers({ username: 'alice', search: undefined }); + expect(sentUrl().searchParams.has('search')).toBe(false); + expect(sentUrl().searchParams.get('username')).toBe('alice'); + }); + + it('treats a non-list body as no users when emulating filters', async () => { + nativeUserFilters = false; + respond({ message: 'unexpected' }); + expect(await fetchUsers({ humans: true })).toEqual([]); + }); + it('emulates humans on older instances: project bots server-side, other bots client-side', async () => { nativeUserFilters = false; respond(users); diff --git a/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts b/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts index cfcd5d8f6..fb617cfc7 100644 --- a/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts @@ -5,6 +5,14 @@ import { ConnectionManager } from '../../../src/services/ConnectionManager'; jest.mock('../../../src/services/ConnectionManager'); jest.mock('../../../src/graphql/workItems', () => ({ GET_WORK_ITEM_TYPES: 'GET_WORK_ITEM_TYPES_QUERY', + GET_PROJECT_OR_GROUP_WORK_ITEM_TYPES: 'GET_PROJECT_OR_GROUP_WORK_ITEM_TYPES_QUERY', +})); + +// Whether the simulated schema has Namespace.workItemTypes. A plain variable so +// resetAllMocks below cannot clear it. +let namespaceWorkItemTypes = true; +jest.mock('../../../src/entities/instance-version', () => ({ + graphqlSupports: () => namespaceWorkItemTypes, })); const mockClient = { @@ -24,6 +32,40 @@ describe('workItemTypes utils', () => { mockGetInstance.mockReturnValue({ getClient: () => mockClient, }); + namespaceWorkItemTypes = true; + }); + + describe('getWorkItemTypes on instances without Namespace.workItemTypes', () => { + const types = [{ id: 'gid://gitlab/WorkItems::Type/2', name: 'Issue' }]; + + beforeEach(() => { + namespaceWorkItemTypes = false; + }); + + it('reads the types from the project', async () => { + mockClient.request.mockResolvedValue({ project: { workItemTypes: { nodes: types } } }); + + const result = await getWorkItemTypes('grp/proj'); + + expect(mockClient.request).toHaveBeenCalledWith( + 'GET_PROJECT_OR_GROUP_WORK_ITEM_TYPES_QUERY', + { namespacePath: 'grp/proj' }, + ); + expect(result).toEqual(types); + }); + + it('reads the types from the group when the path is not a project', async () => { + mockClient.request.mockResolvedValue({ + project: null, + group: { workItemTypes: { nodes: types } }, + }); + expect(await getWorkItemTypes('grp')).toEqual(types); + }); + + it('returns no types when the path is neither', async () => { + mockClient.request.mockResolvedValue({ project: null, group: null }); + expect(await getWorkItemTypes('missing')).toEqual([]); + }); }); describe('getWorkItemTypes', () => { From 8dc1c76291181017f600112d358e138834845280 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:45:47 +0300 Subject: [PATCH 06/25] fix(registry): enforce per-action version and tier requirements Requirements declared for a single action were never applied: the registry checked only the tool default, so an action the instance cannot serve stayed in the schema and its call reached GitLab. The registry now removes such actions from the tool schema, hides a tool left with none, and refuses a call to one with the unmet requirement as the reason. Covered by a registry test that failed before the change. The version compatibility table in the docs now describes the work item fallbacks instead of claiming older instances lose most actions. --- .../docs/advanced/context-switching.md | 12 ++-- packages/gitlab-mcp/src/registry-manager.ts | 46 +++++++++++-- .../src/services/InstanceCapabilities.ts | 17 +++++ packages/gitlab-mcp/src/types.ts | 5 ++ packages/gitlab-mcp/src/utils/schema-utils.ts | 67 +++++++++++++++---- .../tests/unit/RegistryManager.test.ts | 61 +++++++++++++++++ .../services/ToolDescriptionOverrides.test.ts | 1 + 7 files changed, 184 insertions(+), 25 deletions(-) diff --git a/packages/gitlab-mcp/docs/advanced/context-switching.md b/packages/gitlab-mcp/docs/advanced/context-switching.md index 1b93b8b26..31c6c3f6b 100644 --- a/packages/gitlab-mcp/docs/advanced/context-switching.md +++ b/packages/gitlab-mcp/docs/advanced/context-switching.md @@ -120,15 +120,15 @@ On instance switch: ### Version Compatibility -The server supports GitLab 16.0 and later. Tools and actions that rely on newer API -surface are hidden on older instances, for example: +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+ | Full support, including namespace-level listing | -| 17.10 - 18.0 | Get, create, update, delete, links; no listing | -| 16.4 - 17.9 | Delete and link/unlink only | -| 16.0 - 16.3 | Delete only | +| 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 diff --git a/packages/gitlab-mcp/src/registry-manager.ts b/packages/gitlab-mcp/src/registry-manager.ts index 6aa1adf1e..37550fc2f 100644 --- a/packages/gitlab-mcp/src/registry-manager.ts +++ b/packages/gitlab-mcp/src/registry-manager.ts @@ -94,7 +94,11 @@ import { USE_VULNERABILITIES, getToolDescriptionOverrides, } from './config'; -import { isToolAvailable, getRestrictedParameters } from './services/InstanceCapabilities'; +import { + isToolAvailable, + getRestrictedParameters, + getUnavailableActions, +} from './services/InstanceCapabilities'; import { ConnectionManager } from './services/ConnectionManager'; import { HealthMonitor } from './services/HealthMonitor'; import { isToolAvailableForScopes } from './services/TokenScopeDetector'; @@ -111,6 +115,8 @@ import { resolveRelatedReferences, stripRelatedSection } from './utils/descripti import { normalizeInstanceUrl } from './utils/url'; import { getGitLabApiUrlFromContext } from './oauth/token-context'; +const NONE_UNAVAILABLE: ReadonlyMap = new Map(); + /** * Central registry manager that aggregates tools from all entity registries * and provides a unified interface for tool discovery and execution @@ -451,6 +457,7 @@ class RegistryManager { instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; tokenScopes?: GitLabScope[]; }, + unavailableActions: ReadonlyMap, ): 'readOnly' | 'deniedRegex' | 'scopes' | 'tier' | 'admin' | 'actionDenial' | null { if (GITLAB_READ_ONLY_MODE && !this.getReadOnlyTools().includes(toolName)) return 'readOnly'; if (GITLAB_DENIED_TOOLS_REGEX?.test(toolName)) return 'deniedRegex'; @@ -472,10 +479,24 @@ class RegistryManager { if (!isToolAvailable(tool.requirements, ctx.instanceInfo)) return 'tier'; } const allActions = extractActionsFromSchema(tool.inputSchema); - if (allActions.length > 0 && shouldRemoveTool(toolName, allActions)) return 'actionDenial'; + if (allActions.length > 0) { + if (shouldRemoveTool(toolName, allActions)) return 'actionDenial'; + // Every action left is one the instance cannot serve. + if (shouldRemoveTool(toolName, allActions, unavailableActions)) return 'tier'; + } return null; } + /** Actions of a GitLab-backed tool the instance cannot serve (none for context tools). */ + private unavailableActionsFor( + toolName: string, + tool: EnhancedToolDefinition, + ctx: { instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean } }, + ): ReadonlyMap { + if (!ctx.instanceInfo || this.registries.get('context')?.has(toolName)) return NONE_UNAVAILABLE; + return getUnavailableActions(tool.requirements, ctx.instanceInfo); + } + /** Filter registries and build transformed tool map (schema + description overrides). */ private buildFilteredTools(ctx: { instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; @@ -485,13 +506,14 @@ class RegistryManager { for (const [, registry] of this.registries) { for (const [toolName, tool] of registry) { - const exclusion = this.getToolExclusionReason(toolName, tool, ctx); + const unavailableActions = this.unavailableActionsFor(toolName, tool, ctx); + const exclusion = this.getToolExclusionReason(toolName, tool, ctx, unavailableActions); if (exclusion) { logDebug('Tool filtered out', { toolName, reason: exclusion }); continue; } - let transformedSchema = transformToolSchema(toolName, tool.inputSchema); + let transformedSchema = transformToolSchema(toolName, tool.inputSchema, unavailableActions); // Strip restricted parameters (skip only when not initialized). When version // is unknown, getRestrictedParameters fail-opens version/tier but still strips @@ -508,6 +530,7 @@ class RegistryManager { ...tool, inputSchema: transformedSchema, ...(customDescription && { description: customDescription }), + ...(unavailableActions.size > 0 && { unavailableActions }), }; if (customDescription) { @@ -686,6 +709,14 @@ class RegistryManager { throw new Error(`Tool '${toolName}' not found in any registry`); } + // The schema no longer offers an action the instance cannot serve, but a + // client may still send it; refuse with the reason instead of calling GitLab. + const action = (args as { action?: unknown } | null)?.action; + if (tool.unavailableActions && typeof action === 'string') { + const reason = tool.unavailableActions.get(action.toLowerCase()); + if (reason) throw new Error(`Action '${action}' of ${toolName} is unavailable: ${reason}`); + } + return await tool.handler(args); } @@ -1067,7 +1098,12 @@ class RegistryManager { for (const registry of this.registries.values()) { for (const [toolName, tool] of registry) { if (contextTools && !contextTools.has(toolName)) continue; - const reason = this.getToolExclusionReason(toolName, tool, ctx); + const reason = this.getToolExclusionReason( + toolName, + tool, + ctx, + this.unavailableActionsFor(toolName, tool, ctx), + ); if (!reason) { counts.available++; } else { diff --git a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts index 0bcfe2c20..2aca2fc5e 100644 --- a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts +++ b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts @@ -139,6 +139,23 @@ export function getRestrictedParameters( .map(([name]) => name); } +/** + * Actions whose own requirement the instance does not meet, keyed by lowercase + * action name with the reason. Actions without an override follow the tool + * default, which gates the whole tool instead. + */ +export function getUnavailableActions( + reqs: ToolRequirements | undefined, + caps: CapabilityGate, +): Map { + const unavailable = new Map(); + for (const action of Object.keys(reqs?.actions ?? {})) { + const reason = getUnmetReason(reqs, caps, action); + if (reason) unavailable.set(action.toLowerCase(), reason); + } + return unavailable; +} + /** * Human-readable reason a tool/action is unavailable, or null when available. * Intended for diagnostics that explain why a tool was filtered. diff --git a/packages/gitlab-mcp/src/types.ts b/packages/gitlab-mcp/src/types.ts index d7e14d3cb..061a97e3d 100644 --- a/packages/gitlab-mcp/src/types.ts +++ b/packages/gitlab-mcp/src/types.ts @@ -66,6 +66,11 @@ export interface EnhancedToolDefinition extends ToolDefinition { * error. Tools without requirements fall through to a conservative gate. */ requirements?: ToolRequirements; + /** + * Set by the registry on its per-instance copy: actions (lowercase) whose own + * requirement the instance does not meet, with the reason they are refused. + */ + unavailableActions?: ReadonlyMap; /** * Mark the tool as idempotent (safe to retry on failure). * If not specified, idempotency is inferred from tool name: diff --git a/packages/gitlab-mcp/src/utils/schema-utils.ts b/packages/gitlab-mcp/src/utils/schema-utils.ts index 296e9d00f..fcacf8e80 100644 --- a/packages/gitlab-mcp/src/utils/schema-utils.ts +++ b/packages/gitlab-mcp/src/utils/schema-utils.ts @@ -88,18 +88,43 @@ interface JSONSchema { // Core Transformation Functions // ============================================================================ +/** Lowercase action names; satisfied by a Set and by a Map keyed by action. */ +export interface ActionNames { + readonly size: number; + has(action: string): boolean; + keys(): Iterable; +} + +const NO_ACTIONS: ActionNames = new Set(); + +/** + * Lowercase actions removed from a tool: denied by configuration, plus those the + * connected instance cannot serve. + */ +function removedActions(toolName: string, unavailable: ActionNames): ActionNames { + const denied = GITLAB_DENIED_ACTIONS.get(toolName.toLowerCase()); + if (unavailable.size === 0) return denied ?? NO_ACTIONS; + if (!denied || denied.size === 0) return unavailable; + return new Set([...denied, ...unavailable.keys()]); +} + /** * Filter branches from a discriminated union JSON schema based on denied actions * * @param schema - JSON schema with oneOf (discriminated union) * @param toolName - Tool name for looking up denied actions + * @param unavailable - Lowercase actions the instance cannot serve * @returns Filtered schema with denied action branches removed */ -export function filterDiscriminatedUnionActions(schema: JSONSchema, toolName: string): JSONSchema { - const deniedActions = GITLAB_DENIED_ACTIONS.get(toolName.toLowerCase()); +export function filterDiscriminatedUnionActions( + schema: JSONSchema, + toolName: string, + unavailable: ActionNames = NO_ACTIONS, +): JSONSchema { + const deniedActions = removedActions(toolName, unavailable); // If no oneOf, this isn't a discriminated union - return as-is - if (!schema.oneOf || !deniedActions || deniedActions.size === 0) { + if (!schema.oneOf || deniedActions.size === 0) { return schema; } @@ -372,17 +397,22 @@ function getSchemaMode(): 'flat' | 'discriminated' { * * @param toolName - Tool name * @param inputSchema - Original JSON schema (may be discriminated union or flat) + * @param unavailableActions - Lowercase actions the connected instance cannot serve * @returns Transformed JSON schema ready for clients */ -export function transformToolSchema(toolName: string, inputSchema: JSONSchema): JSONSchema { +export function transformToolSchema( + toolName: string, + inputSchema: JSONSchema, + unavailableActions: ActionNames = NO_ACTIONS, +): JSONSchema { let schema = inputSchema; - // Step 1: Filter denied actions + // Step 1: Filter denied and unavailable actions if (schema.oneOf) { - schema = filterDiscriminatedUnionActions(schema, toolName); + schema = filterDiscriminatedUnionActions(schema, toolName, unavailableActions); } else if (schema.properties?.action?.enum) { // Flat schema with action enum - filter the enum directly - schema = filterFlatSchemaActions(schema, toolName); + schema = filterFlatSchemaActions(schema, toolName, unavailableActions); } // Step 2: Apply description overrides (works on oneOf or flat) @@ -401,10 +431,14 @@ export function transformToolSchema(toolName: string, inputSchema: JSONSchema): * Filter actions from a flat schema (legacy support) * Used for schemas that haven't been migrated to discriminated union yet */ -function filterFlatSchemaActions(schema: JSONSchema, toolName: string): JSONSchema { - const deniedActions = GITLAB_DENIED_ACTIONS.get(toolName.toLowerCase()); +function filterFlatSchemaActions( + schema: JSONSchema, + toolName: string, + unavailable: ActionNames, +): JSONSchema { + const deniedActions = removedActions(toolName, unavailable); - if (!deniedActions || deniedActions.size === 0) { + if (deniedActions.size === 0) { return schema; } @@ -493,11 +527,16 @@ function stripFromProperties(schema: JSONSchema, restrictedParams: Set): // ============================================================================ /** - * Check if all actions are denied for a tool + * Check if all actions of a tool are denied by configuration or, when given, + * unavailable on the connected instance */ -export function shouldRemoveTool(toolName: string, allActions: string[]): boolean { - const deniedActions = GITLAB_DENIED_ACTIONS.get(toolName.toLowerCase()); - if (!deniedActions || deniedActions.size === 0) { +export function shouldRemoveTool( + toolName: string, + allActions: string[], + unavailable: ActionNames = NO_ACTIONS, +): boolean { + const deniedActions = removedActions(toolName, unavailable); + if (deniedActions.size === 0) { return false; } diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index 98d84d430..21366f45c 100644 --- a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts @@ -550,6 +550,67 @@ describe('RegistryManager', () => { } }); + describe('action requirements', () => { + // An action gated above the instance version must neither be advertised nor + // reach GitLab; the tool's default requirement alone does not cover it. + const branch = (action: string) => ({ + type: 'object', + properties: { action: { type: 'string', const: action } }, + required: ['action'], + }); + const actionTool = (name: string, actions: string[]) => ({ + name, + description: 'Tool with a version-gated action', + inputSchema: { oneOf: actions.map(branch) }, + requirements: { + default: { tier: 'free' }, + actions: { add_link: { tier: 'free', minVersion: '99.0' } }, + }, + handler: jest.fn().mockResolvedValue('ok'), + }); + const actionsOf = (name: string) => + ( + RegistryManager.getInstance().getTool(name)?.inputSchema as { + oneOf: Array<{ properties: { action: { const: string } } }>; + } + ).oneOf.map((b) => b.properties.action.const); + + it('removes the unavailable action from the schema and refuses to run it', async () => { + const coreRegistry = require('../../src/entities/core/registry').coreToolRegistry; + const tool = actionTool('tool_gated_action', ['list', 'add_link']); + coreRegistry.set('tool_gated_action', tool); + try { + resetRegistryManagerSingleton(); + const manager = RegistryManager.getInstance(); + + expect(actionsOf('tool_gated_action')).toEqual(['list']); + await expect( + manager.executeTool('tool_gated_action', { action: 'add_link' }), + ).rejects.toThrow('Requires GitLab 99.0+, current version is 17.0.0'); + expect(tool.handler).not.toHaveBeenCalled(); + + await expect(manager.executeTool('tool_gated_action', { action: 'list' })).resolves.toBe( + 'ok', + ); + } finally { + coreRegistry.delete('tool_gated_action'); + } + }); + + it('hides the tool when none of its actions is available', () => { + const coreRegistry = require('../../src/entities/core/registry').coreToolRegistry; + coreRegistry.set('tool_only_gated', actionTool('tool_only_gated', ['add_link'])); + try { + resetRegistryManagerSingleton(); + expect(RegistryManager.getInstance().getAvailableToolNames()).not.toContain( + 'tool_only_gated', + ); + } finally { + coreRegistry.delete('tool_only_gated'); + } + }); + }); + it('should skip parameter stripping when ConnectionManager is not initialized', () => { const { ConnectionManager } = require('../../src/services/ConnectionManager'); const coreRegistry = require('../../src/entities/core/registry').coreToolRegistry; diff --git a/packages/gitlab-mcp/tests/unit/services/ToolDescriptionOverrides.test.ts b/packages/gitlab-mcp/tests/unit/services/ToolDescriptionOverrides.test.ts index 8b733023d..a081a02af 100644 --- a/packages/gitlab-mcp/tests/unit/services/ToolDescriptionOverrides.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ToolDescriptionOverrides.test.ts @@ -43,6 +43,7 @@ jest.mock('../../../src/logger', () => ({ jest.mock('../../../src/services/InstanceCapabilities', () => ({ isToolAvailable: jest.fn().mockReturnValue(true), getRestrictedParameters: jest.fn().mockReturnValue([]), + getUnavailableActions: jest.fn().mockReturnValue(new Map()), })); jest.mock('../../../src/services/ConnectionManager', () => ({ From 8d79fcffefaef447edfd3afa85854058cb67c68b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:45:54 +0300 Subject: [PATCH 07/25] fix(core): require GitLab 18.0 for project and group restore on Free On Free 17.11 the restore routes answer 404 unless a disabled-by-default development flag is enabled; 18.0 made delayed deletion unconditional. Restore on Free now requires 18.0, and the action descriptions say so. --- .../gitlab-mcp/src/entities/core/registry.ts | 13 +++++++----- .../gitlab-mcp/src/entities/core/schema.ts | 4 ++-- .../tests/unit/entities/core/registry.test.ts | 20 ++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index 56876d0e5..58af223cd 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -884,9 +884,11 @@ export const coreToolRegistry: ToolRegistry = new Map { expect(result).toEqual({ id: 1, marked_for_deletion_on: null }); }); - it('refuses project restore on GitLab Free before 17.11 but allows it on Premium', async () => { - // Free (CE) got the restore endpoint in 17.11; Premium had it before 16.0. + it('refuses project restore on GitLab Free before 18.0 but allows it on Premium', async () => { + // Free 17.11 has the route, but it answers 404 unless a disabled-by-default + // development flag is on; 18.0 made delayed deletion unconditional on Free. const spy = jest.spyOn(ConnectionManager.getInstance(), 'getInstanceInfo'); try { - spy.mockReturnValue({ version: '17.10.0', tier: 'free' } as GitLabInstanceInfo); + spy.mockReturnValue({ version: '17.11.0', tier: 'free' } as GitLabInstanceInfo); const tool = coreToolRegistry.get('manage_project'); await expect(tool!.handler({ action: 'restore', project_id: '1' })).rejects.toThrow( - 'Project restore on GitLab Free requires GitLab 17.11+', + 'Project restore on GitLab Free requires GitLab 18.0+', ); expect(mockEnhancedFetch).not.toHaveBeenCalled(); @@ -2351,19 +2352,20 @@ describe('Core Registry', () => { ); }); - it('refuses group restore on GitLab Free before 17.11 but allows Premium and later Free', async () => { - // Premium (EE) had group restore before 16.0; Free (CE) got the route in 17.11. + it('refuses group restore on GitLab Free before 18.0 but allows Premium and later Free', async () => { + // Premium had group restore before 16.0; on Free it works from 18.0 (17.11 + // answers 404 unless a disabled-by-default development flag is on). const spy = jest.spyOn(ConnectionManager.getInstance(), 'getInstanceInfo'); try { const tool = coreToolRegistry.get('manage_namespace'); - spy.mockReturnValue({ version: '17.10.0', tier: 'free' } as GitLabInstanceInfo); + spy.mockReturnValue({ version: '17.11.0', tier: 'free' } as GitLabInstanceInfo); await expect(tool!.handler({ action: 'restore', group_id: 'old-group' })).rejects.toThrow( - 'Group restore on GitLab Free requires GitLab 17.11+', + 'Group restore on GitLab Free requires GitLab 18.0+', ); expect(mockEnhancedFetch).not.toHaveBeenCalled(); for (const info of [ - { version: '17.11.0', tier: 'free' }, + { version: '18.0.0', tier: 'free' }, { version: '17.0.0', tier: 'premium' }, ]) { spy.mockReturnValue(info as GitLabInstanceInfo); From 2fb2566cfcd8bfd824203401ea572e9313b820b3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:12 +0300 Subject: [PATCH 08/25] fix(runners): validate the REST owned-runners response A malformed entry in the REST fallback of list_owned crashed the mapping with a TypeError. The response is now validated and a mismatch is reported as an unexpected GitLab response. --- .../src/entities/runners/registry.ts | 42 ++++++++++++------- .../unit/entities/runners/registry.test.ts | 8 ++++ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/runners/registry.ts b/packages/gitlab-mcp/src/entities/runners/registry.ts index 6035afa0a..e674f61d7 100644 --- a/packages/gitlab-mcp/src/entities/runners/registry.ts +++ b/packages/gitlab-mcp/src/entities/runners/registry.ts @@ -94,13 +94,15 @@ function applyRunnerSettings( if (src.maintenance_note !== undefined) target.maintenanceNote = src.maintenance_note; } -interface RestRunner { - id: number; - description: string | null; - runner_type: string; - status: string | null; - paused: boolean; -} +const RestRunnersSchema = z.array( + z.object({ + id: z.number(), + description: z.string().nullable(), + runner_type: z.string(), + status: z.string().nullable(), + paused: z.boolean(), + }), +); /** * The current user's runners through REST, shaped like the GraphQL connection, @@ -119,16 +121,24 @@ async function listOwnedRunnersViaRest(input: { }) { const perPage = input.first ?? 20; const page = Number(input.after) > 0 ? Number(input.after) : 1; - const runners = await gitlab.get('runners', { - query: toQuery({ - type: input.type?.toLowerCase(), - status: input.status?.toLowerCase(), - paused: input.paused, - tag_list: input.tag_list?.join(','), - per_page: perPage, - page, + const parsed = RestRunnersSchema.safeParse( + await gitlab.get('runners', { + query: toQuery({ + type: input.type?.toLowerCase(), + status: input.status?.toLowerCase(), + paused: input.paused, + tag_list: input.tag_list?.join(','), + per_page: perPage, + page, + }), }), - }); + ); + if (!parsed.success) { + throw new Error( + `GitLab API error: unexpected runners response (${parsed.error.issues[0]?.message ?? 'invalid'})`, + ); + } + const runners = parsed.data; const search = input.search?.toLowerCase(); const matching = search ? runners.filter((r) => (r.description ?? '').toLowerCase().includes(search)) diff --git a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts index ee949515a..ea055bfdf 100644 --- a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts @@ -125,6 +125,14 @@ describe('runners registry', () => { expect(res.pageInfo).toEqual({ hasNextPage: true, endCursor: '2' }); }); + it('rejects a malformed runners response with a clear error', async () => { + mockGitlab.get.mockResolvedValueOnce([{ id: 7, description: 'x', paused: false }]); + + await expect(browse().handler({ action: 'list_owned' })).rejects.toThrow( + 'GitLab API error: unexpected runners response', + ); + }); + it('filters by search client-side and ends pagination on a short page', async () => { mockGitlab.get.mockResolvedValueOnce([ { From 13998f3872ef3f2c68ef056f3137e6048523f898 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:16 +0300 Subject: [PATCH 09/25] fix(graphql): adapt pooled instance clients to the instance schema Clients from the multi-instance connection pool had no schema source, so their documents were sent without being adapted to older instances. getInstanceClient now gives them a per-instance provider that reads the instance's introspected schema on each request. --- .../src/services/ConnectionManager.ts | 19 ++++++++++++- .../ConnectionManagerEnhanced.test.ts | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/src/services/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 36d3339cb..fdcf066ef 100644 --- a/packages/gitlab-mcp/src/services/ConnectionManager.ts +++ b/packages/gitlab-mcp/src/services/ConnectionManager.ts @@ -1,6 +1,6 @@ import { GraphQLClient } from '../graphql/client'; import { GitLabVersionDetector, GitLabInstanceInfo } from './GitLabVersionDetector'; -import { SchemaIntrospector, SchemaInfo } from './SchemaIntrospector'; +import { SchemaIntrospector, SchemaInfo, type SchemaFieldIndex } from './SchemaIntrospector'; import { detectTokenScopes, logTokenScopeInfo, @@ -67,6 +67,12 @@ export class ConnectionManager { /** Tracks the most recently requested URL so stale inits don't overwrite currentInstanceUrl. * E.g. init(A) starts, init(B) starts, A finishes last — A must not rebind to itself. */ private latestRequestedUrl: string | null = null; + /** + * Schema source per instance URL for pooled GraphQL clients, which are created + * without one. It looks the instance up on each request, so it follows + * re-initialisation and returns nothing once the instance is evicted. + */ + private readonly schemaIndexProviders = new Map SchemaFieldIndex | undefined>(); /** * Last-access timestamps for per-URL instance entries (epoch ms). @@ -632,6 +638,8 @@ export class ConnectionManager { if (targetUrl && registry.isInitialized() && registry.has(targetUrl)) { const client = registry.getGraphQLClient(targetUrl, authHeaders); if (client) { + // Through the auth proxy this lands on the shared pooled client. + client.setSchemaIndexProvider(this.schemaIndexProviderFor(targetUrl)); return client; } } @@ -650,6 +658,15 @@ export class ConnectionManager { return this.getClient(); } + private schemaIndexProviderFor(url: string): () => SchemaFieldIndex | undefined { + let provider = this.schemaIndexProviders.get(url); + if (!provider) { + provider = () => this.instances.get(url)?.schemaIntrospector.getCachedSchema()?.fieldIndex; + this.schemaIndexProviders.set(url, provider); + } + return provider; + } + public getVersionDetector(instanceUrl?: string): GitLabVersionDetector { const [state] = this.resolveState(instanceUrl); return state.versionDetector; diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts index 69144df3c..02144e428 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts @@ -2,6 +2,7 @@ import { ConnectionManager, type InstanceState } from '../../../src/services/Con import { GraphQLClient } from '../../../src/graphql/client'; import { GitLabVersionDetector } from '../../../src/services/GitLabVersionDetector'; import { SchemaIntrospector } from '../../../src/services/SchemaIntrospector'; +import { InstanceRegistry } from '../../../src/services/InstanceRegistry'; /** Type-safe access to ConnectionManager private fields in tests */ type CMStatic = { instance: ConnectionManager | null; introspectionCache: Map }; @@ -1243,6 +1244,32 @@ describe('ConnectionManager Enhanced Tests', () => { expect(client).toBe(connectionManager.getClient()); }); + it('adapts pooled clients to the instance schema with one provider per instance', async () => { + // Pooled clients are created without the schema; without the provider their + // documents would not be adapted to older instances. + await connectionManager.initialize(); + const fieldIndex = new Map(); + mockSchemaIntrospector.getCachedSchema.mockReturnValue({ ...mockSchemaInfo, fieldIndex }); + const pooled = { setSchemaIndexProvider: jest.fn() } as unknown as GraphQLClient; + const spy = jest.spyOn(InstanceRegistry, 'getInstance').mockReturnValue({ + isInitialized: () => true, + has: () => true, + getGraphQLClient: () => pooled, + } as unknown as InstanceRegistry); + try { + expect(connectionManager.getInstanceClient()).toBe(pooled); + connectionManager.getInstanceClient(); + + const wire = pooled.setSchemaIndexProvider as jest.Mock; + expect(wire).toHaveBeenCalledTimes(2); + // The same provider each time: nothing is allocated per call. + expect(wire.mock.calls[1][0]).toBe(wire.mock.calls[0][0]); + expect((wire.mock.calls[0][0] as () => unknown)()).toBe(fieldIndex); + } finally { + spy.mockRestore(); + } + }); + it('should throw for explicit unregistered instance URL', async () => { await connectionManager.initialize(); From c14d25b3c8f1d5f8aca716547a6700f21e19f27c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:20 +0300 Subject: [PATCH 10/25] fix(users): report search failures and honour exclusion filters - Smart user search no longer turns a failed GitLab call, including the refusal to filter bot users on older instances, into an empty result. - The active/humans defaults are dropped when the caller asks to exclude active users or humans; together they could only ever return nothing. - A /users body that is not a user list is reported as an unexpected response instead of being read as no users. --- .../gitlab-mcp/src/utils/smart-user-search.ts | 114 ++++++++++-------- .../unit/utils/smart-user-search.test.ts | 39 ++++-- 2 files changed, 92 insertions(+), 61 deletions(-) diff --git a/packages/gitlab-mcp/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index 264eb0656..33c5a3395 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -1,3 +1,4 @@ +import * as z from 'zod'; import { enhancedFetch } from './fetch'; import { transliterate } from 'transliteration'; import { instanceAtLeast } from '../entities/instance-version'; @@ -100,11 +101,13 @@ export function analyzeQuery(query: string): QueryPattern { }; } -interface ListedUser { - state?: string; - /** Exposed only in the full user entity (administrators); absent otherwise. */ - bot?: boolean; -} +const ListedUsersSchema = z.array( + z.looseObject({ + state: z.string().optional(), + /** Exposed only in the full user entity (administrators); absent otherwise. */ + bot: z.boolean().optional(), + }), +); /** * GET /users with the user-type filters GitLab added in 17.3 (humans, @@ -128,8 +131,13 @@ export async function fetchUsers(params: Record): Promise user.bot === undefined)) { @@ -149,8 +157,13 @@ export async function fetchUsers(params: Record): Promise { - // Add common defaults for better results - return fetchUsers({ active: true, humans: true, ...params }); + // Default to active humans, unless the caller excludes exactly those: the + // default and the exclusion together can only return nothing. + return fetchUsers({ + ...(params.exclude_active ? {} : { active: true }), + ...(params.exclude_humans ? {} : { humans: true }), + ...params, + }); } /** @@ -162,7 +175,6 @@ export async function smartUserSearch( ): Promise { const pattern = analyzeQuery(query); const searchPhases: Array<{ phase: string; params: UserSearchParams; resultCount: number }> = []; - let users: unknown[] = []; let totalApiCalls = 0; // Phase 1: Targeted search based on detected pattern @@ -179,16 +191,39 @@ export async function smartUserSearch( break; } - try { - users = await callUsersAPI(targetParams); + // A failed call propagates: an empty result would claim nobody matched. + let users = await callUsersAPI(targetParams); + totalApiCalls++; + searchPhases.push({ + phase: `targeted-${pattern.type}`, + params: targetParams, + resultCount: users.length, + }); + + // If we found users, return early + if (users.length > 0) { + return { + users, + searchMetadata: { + query, + pattern, + searchPhases, + totalApiCalls, + }, + }; + } + + // Phase 2: Broad search fallback if targeted search returned empty + if (pattern.type !== 'name') { + const broadParams = { search: pattern.originalQuery, ...additionalParams }; + users = await callUsersAPI(broadParams); totalApiCalls++; searchPhases.push({ - phase: `targeted-${pattern.type}`, - params: targetParams, + phase: 'broad-search', + params: broadParams, resultCount: users.length, }); - // If we found users, return early if (users.length > 0) { return { users, @@ -200,45 +235,18 @@ export async function smartUserSearch( }, }; } + } - // Phase 2: Broad search fallback if targeted search returned empty - if (pattern.type !== 'name') { - const broadParams = { search: pattern.originalQuery, ...additionalParams }; - users = await callUsersAPI(broadParams); - totalApiCalls++; - searchPhases.push({ - phase: 'broad-search', - params: broadParams, - resultCount: users.length, - }); - - if (users.length > 0) { - return { - users, - searchMetadata: { - query, - pattern, - searchPhases, - totalApiCalls, - }, - }; - } - } - - // Phase 3: Transliteration search if query has Cyrillic and no results yet - if (pattern.hasTransliteration && pattern.transliteratedQuery) { - const translitParams = { search: pattern.transliteratedQuery, ...additionalParams }; - users = await callUsersAPI(translitParams); - totalApiCalls++; - searchPhases.push({ - phase: 'transliteration', - params: translitParams, - resultCount: users.length, - }); - } - } catch (error) { - // Log error but don't fail completely - return empty result with metadata - console.error('Smart user search error:', error); + // Phase 3: Transliteration search if query has Cyrillic and no results yet + if (pattern.hasTransliteration && pattern.transliteratedQuery) { + const translitParams = { search: pattern.transliteratedQuery, ...additionalParams }; + users = await callUsersAPI(translitParams); + totalApiCalls++; + searchPhases.push({ + phase: 'transliteration', + params: translitParams, + resultCount: users.length, + }); } return { diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index 420314197..c0aa3560f 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -70,10 +70,12 @@ describe('fetchUsers user-type filters', () => { expect(sentUrl().searchParams.get('username')).toBe('alice'); }); - it('treats a non-list body as no users when emulating filters', async () => { - nativeUserFilters = false; + it('rejects a body that is not a user list instead of reporting no users', async () => { + // A proxy page or error object answered with 200 must not read as "no match". respond({ message: 'unexpected' }); - expect(await fetchUsers({ humans: true })).toEqual([]); + await expect(fetchUsers({ humans: true })).rejects.toThrow( + 'GitLab API error: unexpected users response', + ); }); it('emulates humans on older instances: project bots server-side, other bots client-side', async () => { @@ -335,14 +337,35 @@ describe('smart-user-search utilities', () => { expect(mockEnhancedFetch).toHaveBeenCalledWith(expect.stringContaining('humans=true')); }); - it('should handle API errors gracefully', async () => { + it('propagates API errors instead of reporting no users', async () => { + // An empty result would tell the caller nobody matched when the search failed. mockEnhancedFetch.mockRejectedValueOnce(new Error('Network error')); - const result = await smartUserSearch('ivan'); + await expect(smartUserSearch('ivan')).rejects.toThrow('Network error'); + }); + + it('propagates the refusal to filter bot users on older instances', async () => { + nativeUserFilters = false; + mockEnhancedFetch.mockResolvedValueOnce( + mockApiResponse([{ id: 1, username: 'ivan', state: 'active' }]), + ); + + await expect(smartUserSearch('ivan', { exclude_humans: true })).rejects.toThrow( + 'Filtering to bot users needs GitLab 17.3+', + ); + }); - expect(result.users).toEqual([]); - expect(result.searchMetadata.totalApiCalls).toBe(0); - expect(result.searchMetadata.searchPhases).toHaveLength(0); + it('drops each default that contradicts the requested exclusion', async () => { + // active=true with exclude_active, or humans=true with exclude_humans, + // can only ever return nothing. + mockEnhancedFetch.mockResolvedValueOnce(mockApiResponse([{ id: 1, username: 'ivan' }])); + await smartUserSearch('ivan', { exclude_active: true, exclude_humans: true }); + + const sent = new URL(mockEnhancedFetch.mock.calls[0][0]).searchParams; + expect(sent.has('active')).toBe(false); + expect(sent.has('humans')).toBe(false); + expect(sent.get('exclude_active')).toBe('true'); + expect(sent.get('exclude_humans')).toBe('true'); }); it('should include default filters for better results', async () => { From 9cd2beb37628a0b18e853e27abe2df87146cc9d9 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:23 +0300 Subject: [PATCH 11/25] fix(webhooks): gate webhook name/description and variable description GitLab silently ignores these on older instances while reporting success. Webhook name and description arrived in 17.1 (the descriptions wrongly said 16.11) and CI/CD variable description in 16.2; each is now gated at that version. --- packages/gitlab-mcp/src/entities/variables/registry.ts | 6 +++++- packages/gitlab-mcp/src/entities/webhooks/registry.ts | 2 ++ packages/gitlab-mcp/src/entities/webhooks/schema.ts | 8 ++++---- .../tests/unit/services/InstanceCapabilities.test.ts | 3 +++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/variables/registry.ts b/packages/gitlab-mcp/src/entities/variables/registry.ts index e0e5742ab..bfb52e757 100644 --- a/packages/gitlab-mcp/src/entities/variables/registry.ts +++ b/packages/gitlab-mcp/src/entities/variables/registry.ts @@ -74,7 +74,11 @@ export const variablesToolRegistry: ToolRegistry = new Map { const input = ManageVariableSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/webhooks/registry.ts b/packages/gitlab-mcp/src/entities/webhooks/registry.ts index ef62e739c..2e5e4c483 100644 --- a/packages/gitlab-mcp/src/entities/webhooks/registry.ts +++ b/packages/gitlab-mcp/src/entities/webhooks/registry.ts @@ -92,6 +92,8 @@ export const webhooksToolRegistry: ToolRegistry = new Map { // rest are emulated in the handlers. ['files', 'browse_files', 'download_attachment', undefined, '17.4'], ['webhooks', 'manage_webhook', 'test', undefined, '16.11'], + ['webhooks', 'manage_webhook', undefined, 'name', '17.1'], + ['webhooks', 'manage_webhook', undefined, 'description', '17.1'], ['webhooks', 'manage_webhook', undefined, 'feature_flag_events', '17.5'], + ['variables', 'manage_variable', undefined, 'description', '16.2'], ['webhooks', 'manage_webhook', undefined, 'project_events', '18.2'], ['pipelines', 'manage_pipeline', undefined, 'inputs', '17.10'], ['workitems', 'manage_work_item', 'add_link', undefined, '16.4'], From 9419793aecec4b6b863952c410824fd200ef608f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:27 +0300 Subject: [PATCH 12/25] fix(access-tokens): filter token state before paginating on older GitLab Before 17.2 the state filter was applied to a single unfiltered page, so a match on a later page was lost. GitLab's pages are now walked until the requested filtered page is complete or the list ends. --- .../src/entities/access_tokens/registry.ts | 28 +++++++++++++------ .../entities/access_tokens/registry.test.ts | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts index 3d89605fb..174968d09 100644 --- a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts +++ b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts @@ -3,7 +3,7 @@ import { BrowseAccessTokensSchema } from './schema-readonly'; 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; every endpoint used here @@ -13,17 +13,29 @@ const FREE_REQ = { tier: 'free' } as const; /** * 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. + * 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: Record) { - const { state, ...rest } = query; +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 tokens = await gitlab.get>(path, { - query: toQuery(rest, []), - }); - return tokens.filter((token) => token.active === (state === 'active')); + const wanted = page * perPage; + const matches: Array<{ active?: boolean }> = []; + for (let serverPage = 1; matches.length < wanted; serverPage++) { + const batch = await gitlab.get>(path, { + query: toQuery({ per_page: GITLAB_MAX_PER_PAGE, page: serverPage }, []), + }); + 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 = diff --git a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts index d15ed5c1b..78c3c3e01 100644 --- a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts @@ -118,6 +118,34 @@ describe('Access Tokens Registry', () => { expect(result).toEqual([{ id: 2, active: false }]); }); + it('filters before paginating, walking GitLab pages past non-matching ones', async () => { + // A full first page of inactive tokens must not hide an active one on + // the next page, and the requested page is cut from the filtered list. + atVersion('17.1.0'); + const inactive = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, active: false })); + mockOk(inactive); + mockOk([ + { id: 101, active: true }, + { id: 102, active: true }, + { id: 103, active: true }, + ]); + + const result = await browse().handler({ + action: 'list_project', + project_id: 'p', + state: 'active', + per_page: 2, + page: 2, + }); + + expect(result).toEqual([{ id: 103, active: true }]); + const sent = mockEnhancedFetch.mock.calls.map(([url]) => new URL(String(url)).searchParams); + expect(sent.map((q) => [q.get('page'), q.get('per_page')])).toEqual([ + ['1', '100'], + ['2', '100'], + ]); + }); + it('sends the state filter from 17.2', async () => { atVersion('17.2.0'); mockOk([]); From 36d9d7b2a2a40753d4851b9c1cb72abd6ddaf6e4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 19:46:30 +0300 Subject: [PATCH 13/25] test(core): restore Duo settings on the shared fixtures after each test --- .../schemas-dependent/duo-settings.test.ts | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts index 2260fab47..dba03b5a3 100644 --- a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts @@ -55,6 +55,14 @@ function expectAppliedOrReported( } } +/** The offered settings as they were before the test, for restoring the fixture. */ +function originalValues( + offered: string[], + before: Record, +): Record { + return Object.fromEntries(offered.map((name) => [name, before[name] === true])); +} + describe('GitLab Duo settings - GitLab Integration', () => { let helper: IntegrationTestHelper; @@ -79,15 +87,28 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getProject(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - const updated = (await helper.executeTool( - 'manage_project', - ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), - )) as Entity; - console.log( - `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, - ); + try { + const updated = (await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), + )) as Entity; + console.log( + `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); - expectAppliedOrReported(requested, updated, await getProject()); + expectAppliedOrReported(requested, updated, await getProject()); + } finally { + // The project is a shared fixture: later tests must not inherit, say, + // automatic Duo reviews on their merge requests. + await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ + action: 'update', + project_id: projectId, + ...originalValues(offered, before), + }), + ); + } }, 60000); it('applies or reports group automatic Duo code review', async () => { @@ -110,14 +131,26 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getGroup(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - const updated = (await helper.executeTool( - 'manage_namespace', - ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), - )) as Entity; - console.log( - `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, - ); + try { + const updated = (await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), + )) as Entity; + console.log( + `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); - expectAppliedOrReported(requested, updated, await getGroup()); + expectAppliedOrReported(requested, updated, await getGroup()); + } finally { + // Group settings cascade to every test project; restore them. + await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ + action: 'update', + group_id: groupId, + ...originalValues(offered, before), + }), + ); + } }, 60000); }); From 9d64a4778f9f110640e3d5670794c7838f73990f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 20:53:56 +0300 Subject: [PATCH 14/25] fix(ci-tokens): keep reading the job token scope available on GitLab 16.0 GET job_token_scope predates 16.0, but the whole browse tool was gated at 16.1, hiding it there. The tool now defaults to the supported floor and gates list_projects at 16.1 and list_groups at 16.10, the releases that added their allowlist endpoints. --- .../src/entities/job-token-scope/registry.ts | 9 +++++--- .../entities/job-token-scope/registry.test.ts | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts b/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts index 8388704b7..b71101035 100644 --- a/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts +++ b/packages/gitlab-mcp/src/entities/job-token-scope/registry.ts @@ -27,8 +27,8 @@ function scopeBase(projectId: number): string { return `projects/${projectId}/job_token_scope`; } -// Free tier throughout. The job token scope API (project allowlist, enforcement -// toggle) landed in GitLab 16.1, the group allowlist in 16.10. +// Free tier throughout. Reading the scope predates 16.0; the project allowlist +// and the enforcement toggle landed in GitLab 16.1, the group allowlist in 16.10. const SCOPE_REQ = { tier: 'free', minVersion: '16.1' } as const; const GROUP_REQ = { tier: 'free', minVersion: '16.10' } as const; @@ -53,7 +53,10 @@ export const jobTokenScopeToolRegistry: ToolRegistry = new Map => { const input = BrowseJobTokenScopeSchema.parse(args); diff --git a/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts index e73c0c723..b1c45e2e5 100644 --- a/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts @@ -11,6 +11,7 @@ import { lastFetchCall as lastCall, mockEnhancedFetch, } from '../../helpers/fetch-mock'; +import { isToolAvailable } from '../../../../src/services/InstanceCapabilities'; jest.mock('../../../../src/utils/fetch', () => ({ enhancedFetch: jest.fn(), @@ -41,12 +42,21 @@ describe('Job Token Scope Registry', () => { expect(getJobTokenScopeToolDefinitions()).toHaveLength(2); }); - it('declares free-tier requirements with allowlist minVersions', () => { - // The job token scope API landed in 16.1, its group allowlist in 16.10. - expect(browse().requirements?.default).toEqual({ tier: 'free', minVersion: '16.1' }); - expect(browse().requirements?.actions?.list_groups?.minVersion).toBe('16.10'); - expect(manage().requirements?.actions?.add_group?.minVersion).toBe('16.10'); - expect(manage().requirements?.actions?.remove_group?.minVersion).toBe('16.10'); + it('gates each action by the release that added its endpoint', () => { + // GET job_token_scope predates 16.0; PATCH and the project allowlist came + // in 16.1, the group allowlist in 16.10. Reading the scope must stay + // available on 16.0. + const at = (version: string) => ({ version, tier: 'free' as const }); + const browseReq = browse().requirements; + const manageReq = manage().requirements; + expect(isToolAvailable(browseReq, at('16.0.0'), 'get')).toBe(true); + expect(isToolAvailable(browseReq, at('16.0.0'), 'list_projects')).toBe(false); + expect(isToolAvailable(browseReq, at('16.1.0'), 'list_projects')).toBe(true); + expect(isToolAvailable(browseReq, at('16.9.0'), 'list_groups')).toBe(false); + expect(isToolAvailable(manageReq, at('16.0.0'), 'set_enabled')).toBe(false); + expect(isToolAvailable(manageReq, at('16.1.0'), 'add_project')).toBe(true); + expect(isToolAvailable(manageReq, at('16.9.0'), 'add_group')).toBe(false); + expect(isToolAvailable(manageReq, at('16.10.0'), 'remove_group')).toBe(true); }); it('is gated by the shared USE_CI_TOKENS umbrella flag', () => { From 3723f63901373c6c4a05f6633d9e640328e435d9 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 20:54:00 +0300 Subject: [PATCH 15/25] fix(runners): search owned runners before paginating on older GitLab The REST fallback matched `search` within one unfiltered page, so a match on a later page was lost and a page could come back empty. With a search, REST pages are now walked until the requested page of matches is complete, and the cursor counts pages of matches. --- .../src/entities/runners/registry.ts | 75 ++++++++++++------- .../unit/entities/runners/registry.test.ts | 43 +++++++++-- 2 files changed, 87 insertions(+), 31 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/runners/registry.ts b/packages/gitlab-mcp/src/entities/runners/registry.ts index e674f61d7..a955b2d17 100644 --- a/packages/gitlab-mcp/src/entities/runners/registry.ts +++ b/packages/gitlab-mcp/src/entities/runners/registry.ts @@ -2,7 +2,7 @@ import * as z from 'zod'; import { BrowseRunnersSchema } from './schema-readonly'; import { ManageRunnerSchema } from './schema'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; -import { assertActionAllowed } from '../utils'; +import { assertActionAllowed, GITLAB_MAX_PER_PAGE } from '../utils'; import { ConnectionManager } from '../../services/ConnectionManager'; import { cleanGidsFromObject } from '../../utils/idConversion'; import { getGitLabApiUrlFromContext } from '../../oauth/token-context'; @@ -104,30 +104,22 @@ const RestRunnersSchema = z.array( }), ); -/** - * The current user's runners through REST, shaped like the GraphQL connection, - * for instances without currentUser.runners (GitLab 18.3). REST pages by number, - * so the cursor is the next page number; `search` is matched client-side on the - * description. Fields the REST listing lacks are null. - */ -async function listOwnedRunnersViaRest(input: { +interface OwnedRunnerFilters { type?: string; status?: string; paused?: boolean; tag_list?: string[]; - search?: string; - first?: number; - after?: string; -}) { - const perPage = input.first ?? 20; - const page = Number(input.after) > 0 ? Number(input.after) : 1; +} + +/** One validated page of GET /runners with the server-side filters applied. */ +async function fetchOwnedRunnersPage(filters: OwnedRunnerFilters, perPage: number, page: number) { const parsed = RestRunnersSchema.safeParse( await gitlab.get('runners', { query: toQuery({ - type: input.type?.toLowerCase(), - status: input.status?.toLowerCase(), - paused: input.paused, - tag_list: input.tag_list?.join(','), + type: filters.type?.toLowerCase(), + status: filters.status?.toLowerCase(), + paused: filters.paused, + tag_list: filters.tag_list?.join(','), per_page: perPage, page, }), @@ -138,13 +130,46 @@ async function listOwnedRunnersViaRest(input: { `GitLab API error: unexpected runners response (${parsed.error.issues[0]?.message ?? 'invalid'})`, ); } - const runners = parsed.data; + return parsed.data; +} + +/** + * The current user's runners through REST, shaped like the GraphQL connection, + * for instances without currentUser.runners (GitLab 18.3). REST pages by number, + * so the cursor is the next page number. REST has no `search`, so it is matched + * on the description before paginating: REST pages are walked until the + * requested page of matches is complete, and the cursor counts pages of matches. + * Fields the REST listing lacks are null. + */ +async function listOwnedRunnersViaRest( + input: OwnedRunnerFilters & { search?: string; first?: number; after?: string }, +) { + const perPage = input.first ?? 20; + const page = Number(input.after) > 0 ? Number(input.after) : 1; const search = input.search?.toLowerCase(); - const matching = search - ? runners.filter((r) => (r.description ?? '').toLowerCase().includes(search)) - : runners; + + let runners: z.infer; + let hasNextPage: boolean; + if (search) { + const wanted = page * perPage; + const matches: typeof runners = []; + // One match beyond the requested page tells whether another page exists. + for (let restPage = 1; matches.length <= wanted; restPage++) { + const batch = await fetchOwnedRunnersPage(input, GITLAB_MAX_PER_PAGE, restPage); + for (const runner of batch) { + if ((runner.description ?? '').toLowerCase().includes(search)) matches.push(runner); + } + if (batch.length < GITLAB_MAX_PER_PAGE) break; + } + runners = matches.slice(wanted - perPage, wanted); + hasNextPage = matches.length > wanted; + } else { + runners = await fetchOwnedRunnersPage(input, perPage, page); + hasNextPage = runners.length === perPage; + } + return { - nodes: matching.map((r) => ({ + nodes: runners.map((r) => ({ id: r.id, description: r.description, runnerType: r.runner_type.toUpperCase(), @@ -161,8 +186,8 @@ async function listOwnedRunnersViaRest(input: { createdAt: null, })), pageInfo: { - hasNextPage: runners.length === perPage, - endCursor: runners.length === perPage ? String(page + 1) : null, + hasNextPage, + endCursor: hasNextPage ? String(page + 1) : null, }, }; } diff --git a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts index ea055bfdf..e2b64ef82 100644 --- a/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/runners/registry.test.ts @@ -133,7 +133,38 @@ describe('runners registry', () => { ); }); - it('filters by search client-side and ends pagination on a short page', async () => { + it('searches before paginating, walking REST pages past non-matching ones', async () => { + // A full first REST page without a match must not hide a match on the + // next one; the cursor then counts pages of matches, not REST pages. + const unrelated = Array.from({ length: 100 }, (_, i) => ({ + id: 100 + i, + description: 'build', + runner_type: 'project_type', + status: 'online', + paused: false, + })); + const deploy = (id: number) => ({ + id, + description: `Deploy ${id}`, + runner_type: 'project_type', + status: 'online', + paused: false, + }); + mockGitlab.get.mockResolvedValueOnce(unrelated); + mockGitlab.get.mockResolvedValueOnce([deploy(1), deploy(2), deploy(3)]); + + const res = (await browse().handler({ + action: 'list_owned', + search: 'deploy', + first: 2, + })) as { nodes: Array<{ id: number }>; pageInfo: Record }; + + expect(mockGitlab.get.mock.calls.map((c) => c[1].query.page)).toEqual([1, 2]); + expect(res.nodes.map((n) => n.id)).toEqual([1, 2]); + expect(res.pageInfo).toEqual({ hasNextPage: true, endCursor: '2' }); + }); + + it('returns a later page of matches and ends pagination when matches run out', async () => { mockGitlab.get.mockResolvedValueOnce([ { id: 7, @@ -159,16 +190,16 @@ describe('runners registry', () => { }, ]); - const res = (await browse().handler({ + const first = (await browse().handler({ action: 'list_owned', search: 'deploy', - after: '3', })) as { nodes: Array<{ id: number }>; pageInfo: Record }; - expect(mockGitlab.get.mock.calls[0][1].query).toMatchObject({ page: 3 }); + // The REST listing has no search parameter; it is matched here. + expect(mockGitlab.get.mock.calls[0][1].query).toMatchObject({ page: 1 }); expect(mockGitlab.get.mock.calls[0][1].query).not.toHaveProperty('search'); - expect(res.nodes.map((n) => n.id)).toEqual([7]); - expect(res.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); + expect(first.nodes.map((n) => n.id)).toEqual([7]); + expect(first.pageInfo).toEqual({ hasNextPage: false, endCursor: null }); }); }); From 13b6a6c412a743abba0d5443d3420e62db300023 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 20:54:04 +0300 Subject: [PATCH 16/25] fix(users): filter before paginating and flag partial human filtering On GitLab before 17.3 the emulated user-type filters ran on one unfiltered page, so matches on later pages were lost; GitLab's pages are now walked until the requested filtered page is complete. Without the bot flag (non-admin tokens) humans can only exclude project bots, so such a result now carries a warning: `_warning` next to the list for a plain search, and `searchMetadata.warning` for smart search. --- .../gitlab-mcp/src/entities/core/registry.ts | 7 +- .../gitlab-mcp/src/utils/smart-user-search.ts | 167 +++++++++--------- .../tests/unit/entities/core/registry.test.ts | 27 +++ .../unit/utils/smart-user-search.test.ts | 61 ++++++- 4 files changed, 176 insertions(+), 86 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index 58af223cd..3cd37d44c 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -560,7 +560,12 @@ export const coreToolRegistry: ToolRegistry = new Map; totalApiCalls: number; + /** Set when the returned users may not fully match the requested filters. */ + warning?: string; }; } @@ -109,20 +112,17 @@ const ListedUsersSchema = z.array( }), ); -/** - * GET /users with the user-type filters GitLab added in 17.3 (humans, - * exclude_humans, exclude_active). Older instances ignore them, so there they - * are emulated: exclude_active on each user's state, humans by excluding project - * bots server-side and any user flagged as a bot. exclude_humans needs the bot - * flag, which only the full entity carries. - */ -export async function fetchUsers(params: Record): Promise { - const { humans, exclude_humans, exclude_active, ...rest } = params; - const native = instanceAtLeast('17.3'); - const query: Record = native - ? params - : { ...rest, ...(humans ? { without_project_bots: true } : {}) }; +/** Users from GET /users, and why they may not fully match the requested filters. */ +export interface FetchedUsers { + users: unknown[]; + warning?: string; +} +const PARTIAL_HUMANS_WARNING = + 'Filtered to humans without the bot flag (GitLab before 17.3 shows it only to administrators): bot accounts other than project bots may be included'; + +/** One validated GET /users page. */ +async function fetchUsersPage(query: Record) { const queryParams = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined) queryParams.set(key, String(value)); @@ -137,26 +137,62 @@ export async function fetchUsers(params: Record): Promise user.bot === undefined)) { - throw new Error( - 'Filtering to bot users needs GitLab 17.3+, or an administrator token on older instances', - ); +/** + * GET /users with the user-type filters GitLab added in 17.3 (humans, + * exclude_humans, exclude_active). Older instances ignore them, so there they + * are emulated before pagination: GitLab's pages are walked until the requested + * filtered page is complete. exclude_active checks each user's state; humans + * excludes project bots server-side and any user flagged as a bot, but the bot + * flag reaches administrators only, so without it the result carries a warning. + * exclude_humans cannot work without the flag and is refused. + */ +export async function fetchUsers(params: Record): Promise { + const { humans, exclude_humans, exclude_active, page, per_page, ...filters } = params; + if (instanceAtLeast('17.3') || !(humans || exclude_humans || exclude_active)) { + return { users: await fetchUsersPage(params) }; } - return users.filter( - (user) => - !(humans && user.bot === true) && - !(exclude_humans && user.bot === false) && - !(exclude_active && user.state === 'active'), - ); + + const perPage = typeof per_page === 'number' ? per_page : GITLAB_DEFAULT_PER_PAGE; + const wanted = (typeof page === 'number' ? page : 1) * perPage; + const serverQuery = { ...filters, ...(humans ? { without_project_bots: true } : {}) }; + const matches: unknown[] = []; + let botFlagMissing = false; + for (let serverPage = 1; matches.length < wanted; serverPage++) { + const batch = await fetchUsersPage({ + ...serverQuery, + per_page: GITLAB_MAX_PER_PAGE, + page: serverPage, + }); + if (batch.some((user) => user.bot === undefined)) { + if (exclude_humans) { + throw new Error( + 'Filtering to bot users needs GitLab 17.3+, or an administrator token on older instances', + ); + } + botFlagMissing = true; + } + for (const user of batch) { + if ( + !(humans && user.bot === true) && + !(exclude_humans && user.bot === false) && + !(exclude_active && user.state === 'active') + ) { + matches.push(user); + } + } + if (batch.length < GITLAB_MAX_PER_PAGE) break; + } + const users = matches.slice(wanted - perPage, wanted); + return humans && botFlagMissing ? { users, warning: PARTIAL_HUMANS_WARNING } : { users }; } /** * Make GitLab Users API call with given parameters */ -async function callUsersAPI(params: UserSearchParams): Promise { +async function callUsersAPI(params: UserSearchParams): Promise { // Default to active humans, unless the caller excludes exactly those: the // default and the exclusion together can only return nothing. return fetchUsers({ @@ -192,70 +228,43 @@ export async function smartUserSearch( } // A failed call propagates: an empty result would claim nobody matched. - let users = await callUsersAPI(targetParams); - totalApiCalls++; - searchPhases.push({ - phase: `targeted-${pattern.type}`, - params: targetParams, - resultCount: users.length, + const runPhase = async (phase: string, params: UserSearchParams): Promise => { + const fetched = await callUsersAPI(params); + totalApiCalls++; + searchPhases.push({ phase, params, resultCount: fetched.users.length }); + return fetched; + }; + // The returned users come from the last phase run, and so does its warning. + const finish = ({ users, warning }: FetchedUsers): SmartSearchResult => ({ + users, + searchMetadata: { + query, + pattern, + searchPhases, + totalApiCalls, + ...(warning ? { warning } : {}), + }, }); - // If we found users, return early - if (users.length > 0) { - return { - users, - searchMetadata: { - query, - pattern, - searchPhases, - totalApiCalls, - }, - }; - } + let fetched = await runPhase(`targeted-${pattern.type}`, targetParams); + if (fetched.users.length > 0) return finish(fetched); // Phase 2: Broad search fallback if targeted search returned empty if (pattern.type !== 'name') { - const broadParams = { search: pattern.originalQuery, ...additionalParams }; - users = await callUsersAPI(broadParams); - totalApiCalls++; - searchPhases.push({ - phase: 'broad-search', - params: broadParams, - resultCount: users.length, + fetched = await runPhase('broad-search', { + search: pattern.originalQuery, + ...additionalParams, }); - - if (users.length > 0) { - return { - users, - searchMetadata: { - query, - pattern, - searchPhases, - totalApiCalls, - }, - }; - } + if (fetched.users.length > 0) return finish(fetched); } // Phase 3: Transliteration search if query has Cyrillic and no results yet if (pattern.hasTransliteration && pattern.transliteratedQuery) { - const translitParams = { search: pattern.transliteratedQuery, ...additionalParams }; - users = await callUsersAPI(translitParams); - totalApiCalls++; - searchPhases.push({ - phase: 'transliteration', - params: translitParams, - resultCount: users.length, + fetched = await runPhase('transliteration', { + search: pattern.transliteratedQuery, + ...additionalParams, }); } - return { - users, - searchMetadata: { - query, - pattern, - searchPhases, - totalApiCalls, - }, - }; + return finish(fetched); } diff --git a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts index 11ce363d0..7eb91211d 100644 --- a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts @@ -1642,6 +1642,33 @@ describe('Core Registry', () => { expect(calledUrl).toContain('per_page=50'); }); + it('reports a humans filter it could only partly apply on older GitLab', async () => { + // Before 17.3 non-admin tokens do not see the bot flag, so the list may + // still hold bots other than project bots; the result must say so. + const spy = jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version: '17.2.0', tier: 'free' } as GitLabInstanceInfo); + try { + mockEnhancedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue([{ id: 1, username: 'alice', state: 'active' }]), + } as any); + + const tool = coreToolRegistry.get('browse_users'); + const result = (await tool!.handler({ + action: 'search', + smart_search: false, + humans: true, + })) as { users: Array<{ id: number }>; _warning: string }; + + expect(result.users.map((u) => u.id)).toEqual([1]); + expect(result._warning).toContain('bot accounts other than project bots may be included'); + } finally { + spy.mockRestore(); + } + }); + it('should handle API error for browse_users', async () => { // Test: Error handling for user listing mockEnhancedFetch.mockResolvedValueOnce({ diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index c0aa3560f..bca3b0bc1 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -78,29 +78,64 @@ describe('fetchUsers user-type filters', () => { ); }); + const ids = (result: { users: unknown[] }) => + (result.users as Array<{ id: number }>).map((u) => u.id); + it('emulates humans on older instances: project bots server-side, other bots client-side', async () => { nativeUserFilters = false; respond(users); - const result = (await fetchUsers({ humans: true })) as Array<{ id: number }>; + const result = await fetchUsers({ humans: true }); expect(sentUrl().searchParams.get('humans')).toBeNull(); expect(sentUrl().searchParams.get('without_project_bots')).toBe('true'); - expect(result.map((u) => u.id)).toEqual([1, 3]); + expect(ids(result)).toEqual([1, 3]); + expect(result.warning).toBeUndefined(); + }); + + it('warns that other bots may remain when humans is emulated without the bot flag', async () => { + // Non-admin tokens on older GitLab do not see the bot flag: only project + // bots can be excluded, so the result must not claim to be humans only. + nativeUserFilters = false; + respond([{ id: 1, username: 'alice', state: 'active' }]); + + const result = await fetchUsers({ humans: true }); + + expect(ids(result)).toEqual([1]); + expect(result.warning).toContain('bot accounts other than project bots may be included'); + }); + + it('filters before paginating, walking GitLab pages past excluded users', async () => { + // A full first page of active users must not hide inactive ones on the next + // page, and the requested page is cut from the filtered list. + nativeUserFilters = false; + respond(Array.from({ length: 100 }, (_, i) => ({ id: 100 + i, state: 'active', bot: false }))); + respond([ + { id: 1, state: 'blocked', bot: false }, + { id: 2, state: 'blocked', bot: false }, + { id: 3, state: 'blocked', bot: false }, + ]); + + const result = await fetchUsers({ exclude_active: true, per_page: 2, page: 2 }); + + expect(ids(result)).toEqual([3]); + const sent = mockEnhancedFetch.mock.calls.map(([url]) => new URL(String(url)).searchParams); + expect(sent.map((q) => [q.get('page'), q.get('per_page')])).toEqual([ + ['1', '100'], + ['2', '100'], + ]); }); it('emulates exclude_active on each user state', async () => { nativeUserFilters = false; respond(users); - const result = (await fetchUsers({ exclude_active: true })) as Array<{ id: number }>; - expect(result.map((u) => u.id)).toEqual([3]); + expect(ids(await fetchUsers({ exclude_active: true }))).toEqual([3]); }); it('emulates exclude_humans when the response carries the bot flag', async () => { nativeUserFilters = false; respond(users); - const result = (await fetchUsers({ exclude_humans: true })) as Array<{ id: number }>; - expect(result.map((u) => u.id)).toEqual([2]); + expect(ids(await fetchUsers({ exclude_humans: true }))).toEqual([2]); }); it('refuses exclude_humans when the response lacks the bot flag (non-admin, older GitLab)', async () => { @@ -355,6 +390,20 @@ describe('smart-user-search utilities', () => { ); }); + it('carries the partial-humans warning of the phase whose users it returns', async () => { + // Smart search applies humans by default; on older GitLab without the bot + // flag the result must not look fully filtered. + nativeUserFilters = false; + mockEnhancedFetch.mockResolvedValueOnce( + mockApiResponse([{ id: 1, username: 'ivan', state: 'active' }]), + ); + + const result = await smartUserSearch('ivan'); + + expect(result.users).toHaveLength(1); + expect(result.searchMetadata.warning).toContain('bot accounts other than project bots'); + }); + it('drops each default that contradicts the requested exclusion', async () => { // active=true with exclude_active, or humans=true with exclude_humans, // can only ever return nothing. From 1e562cbe43c3d8f51492af520201f4e998523dd7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 20:54:08 +0300 Subject: [PATCH 17/25] fix(workitems): refuse create widgets the instance cannot set at all A widget missing from both the create and the update input was deferred to the follow-up update, where GitLab rejected the whole mutation and so dropped the supported deferred widgets too. Such a widget is now refused before anything is created, with the same check update already used. --- .../src/entities/workitems/registry.ts | 33 ++++++++++++------- .../workitems/schema-fallbacks.test.ts | 20 +++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/workitems/registry.ts b/packages/gitlab-mcp/src/entities/workitems/registry.ts index 6a75d82b1..db6442100 100644 --- a/packages/gitlab-mcp/src/entities/workitems/registry.ts +++ b/packages/gitlab-mcp/src/entities/workitems/registry.ts @@ -66,6 +66,24 @@ const unwrapWidget = (value: object): unknown => /** Whether this instance's workItemCreate input accepts the field. */ const createSupports = (field: string): boolean => graphqlSupports('WorkItemCreateInput', field); +/** + * Refuse widgets this instance's update input lacks: they cannot be emulated, + * and sending one makes GitLab reject the whole mutation, supported widgets + * included. Names the tool parameters instead. + */ +function assertUpdatableWidgets(input: object, verb: 'set' | 'update'): void { + const unsupported = (Object.keys(input) as Array) + .filter((key): key is UpdateWidgetKey => key.endsWith('Widget')) + .filter((key) => !graphqlSupports('WorkItemUpdateInput', key)); + if (unsupported.length > 0) { + throw new Error( + `This GitLab instance cannot ${verb} ${unsupported + .map((key) => WIDGET_PROPERTY[key]) + .join(', ')} on work items`, + ); + } +} + /** * List a namespace's work items: the namespace-level query when the instance has * it, otherwise the project listing, then the group one. @@ -672,6 +690,8 @@ export const workitemsToolRegistry: ToolRegistry = new Map) - .filter((key): key is UpdateWidgetKey => key.endsWith('Widget')) - .filter((key) => !graphqlSupports('WorkItemUpdateInput', key)); - if (unsupported.length > 0) { - throw new Error( - `This GitLab instance cannot update ${unsupported - .map((key) => WIDGET_PROPERTY[key]) - .join(', ')} on work items`, - ); - } + assertUpdatableWidgets(updateInput, 'update'); // Use single GraphQL mutation with dynamic input const response = await client.request(UPDATE_WORK_ITEM, { input: updateInput }); diff --git a/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts index 18af23c58..f32bf35f5 100644 --- a/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts @@ -245,6 +245,26 @@ describe('manage_work_item create on an older create input', () => { }); }); + it('refuses before creating when a widget is in neither the create nor the update input', async () => { + // Deferring it would make GitLab reject the whole follow-up update, losing + // the supported deferred widgets too; nothing is created instead. + missing.add('WorkItemCreateInput.assigneesWidget'); + missing.add('WorkItemCreateInput.progressWidget'); + missing.add('WorkItemUpdateInput.progressWidget'); + + await expect( + manage().handler({ + action: 'create', + namespace: 'grp/proj', + title: 't', + workItemType: 'Issue', + assigneeIds: ['4'], + progressCurrentValue: 50, + }), + ).rejects.toThrow('This GitLab instance cannot set progressCurrentValue on work items'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + it('sends a single create when the instance accepts every widget', async () => { mockRequest.mockResolvedValueOnce({ workItemCreate: { workItem: item('1'), errors: [] } }); From 9c8d33f1b3d6ca96f6e1128d89ea6022ed851fbd Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:07:56 +0300 Subject: [PATCH 18/25] fix(core): validate REST responses on the commit diff and token fallbacks - Emulated unidiff headers were built from an unchecked body, so a diff missing its paths produced "a/undefined" headers; the diff list is now validated and a mismatch is reported as an unexpected response. - Token pages filtered client-side before 17.2 are validated the same way, since a token without `active` would silently fail the filter. --- .../src/entities/access_tokens/registry.ts | 19 ++++++++++--- .../gitlab-mcp/src/entities/core/registry.ts | 27 ++++++++++++------- .../entities/access_tokens/registry.test.ts | 9 +++++++ .../tests/unit/entities/core/registry.test.ts | 17 ++++++++++++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts index 174968d09..71173cb8f 100644 --- a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts +++ b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts @@ -10,6 +10,9 @@ import { instanceAtLeast } from '../instance-version'; // 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 @@ -25,11 +28,19 @@ async function listScopedTokens( return gitlab.get(path, { query: toQuery(query, []) }); } const wanted = page * perPage; - const matches: Array<{ active?: boolean }> = []; + const matches: Array[number]> = []; for (let serverPage = 1; matches.length < wanted; serverPage++) { - const batch = await gitlab.get>(path, { - query: toQuery({ per_page: GITLAB_MAX_PER_PAGE, page: serverPage }, []), - }); + 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); } diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index 3cd37d44c..dbc6ccd8f 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -56,13 +56,16 @@ async function restoreEntity(apiUrl: string): Promise { return restored.data; } -interface CommitDiff { - diff: string; - old_path: string; - new_path: string; - new_file: boolean; - deleted_file: boolean; -} +const CommitDiffsSchema = z.array( + z.looseObject({ + diff: z.string(), + old_path: z.string(), + new_path: z.string(), + new_file: z.boolean(), + deleted_file: z.boolean(), + }), +); +type CommitDiff = z.infer[number]; /** * Prefix a commit diff with unified-diff file headers, as GitLab's own `unidiff` @@ -445,8 +448,14 @@ export const coreToolRegistry: ToolRegistry = new Map ({ ...d, diff: withUnifiedHeaders(d) })); + if (!unidiff || nativeUnidiff) return diffs; + const parsed = CommitDiffsSchema.safeParse(diffs); + if (!parsed.success) { + throw new Error( + `GitLab API error: unexpected commit diff response (${parsed.error.issues[0]?.message ?? 'invalid'})`, + ); + } + return parsed.data.map((d) => ({ ...d, diff: withUnifiedHeaders(d) })); } /* istanbul ignore next -- unreachable with Zod discriminatedUnion */ diff --git a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts index 78c3c3e01..7d7c048dc 100644 --- a/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/access_tokens/registry.test.ts @@ -146,6 +146,15 @@ describe('Access Tokens Registry', () => { ]); }); + it('rejects a malformed token page when filtering client-side', async () => { + // Without an `active` flag every token would silently fail the filter. + atVersion('17.1.0'); + mockOk([{ id: 1 }]); + await expect( + browse().handler({ action: 'list_project', project_id: 'p', state: 'active' }), + ).rejects.toThrow('GitLab API error: unexpected access tokens response'); + }); + it('sends the state filter from 17.2', async () => { atVersion('17.2.0'); mockOk([]); diff --git a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts index 7eb91211d..e586c596a 100644 --- a/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/core/registry.test.ts @@ -1246,6 +1246,23 @@ describe('Core Registry', () => { } }); + it('rejects a malformed diff list when emulating unidiff', async () => { + // The headers are built from old_path/new_path/new_file/deleted_file; a + // body lacking them must fail loudly, not produce "a/undefined" headers. + const spy = jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version: '16.4.0', tier: 'free' } as GitLabInstanceInfo); + try { + mockEnhancedFetch.mockResolvedValueOnce(okJson([{ diff: '@@ -1 +1 @@\n-a\n+b\n' }])); + const tool = coreToolRegistry.get('browse_commits'); + await expect( + tool!.handler({ action: 'diff', project_id: '123', sha: 'abc123', unidiff: true }), + ).rejects.toThrow('GitLab API error: unexpected commit diff response'); + } finally { + spy.mockRestore(); + } + }); + it('should handle API error for list action', async () => { // Test: Error handling for commit list mockEnhancedFetch.mockResolvedValueOnce({ From f037951379a9afab2f3ba102c8c9368e1194c407 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:08:00 +0300 Subject: [PATCH 19/25] fix(users): bound the pages scanned when emulating user filters On GitLab before 17.3 a filter matching few users could walk every /users page of a large instance in one call. The walk now stops after 20 pages (2000 users) and the result says later matches may be missing, alongside any partial-humans warning. --- .../gitlab-mcp/src/utils/smart-user-search.ts | 18 ++++++++++++++++-- .../tests/unit/utils/smart-user-search.test.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/gitlab-mcp/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index f70ebafde..e42d5edd5 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -121,6 +121,10 @@ export interface FetchedUsers { const PARTIAL_HUMANS_WARNING = 'Filtered to humans without the bot flag (GitLab before 17.3 shows it only to administrators): bot accounts other than project bots may be included'; +/** Pages of /users one call may scan while emulating filters, bounding its requests. */ +const MAX_EMULATED_PAGES = 20; +const TRUNCATED_WARNING = `Filtered client-side (GitLab before 17.3) and only the first ${MAX_EMULATED_PAGES * GITLAB_MAX_PER_PAGE} users were scanned: later matches may be missing; narrow the search to reach them`; + /** One validated GET /users page. */ async function fetchUsersPage(query: Record) { const queryParams = new URLSearchParams(); @@ -147,7 +151,8 @@ async function fetchUsersPage(query: Record) { * filtered page is complete. exclude_active checks each user's state; humans * excludes project bots server-side and any user flagged as a bot, but the bot * flag reaches administrators only, so without it the result carries a warning. - * exclude_humans cannot work without the flag and is refused. + * exclude_humans cannot work without the flag and is refused. The walk stops + * after MAX_EMULATED_PAGES, with a warning that later matches may be missing. */ export async function fetchUsers(params: Record): Promise { const { humans, exclude_humans, exclude_active, page, per_page, ...filters } = params; @@ -160,7 +165,12 @@ export async function fetchUsers(params: Record): Promise MAX_EMULATED_PAGES) { + truncated = true; + break; + } const batch = await fetchUsersPage({ ...serverQuery, per_page: GITLAB_MAX_PER_PAGE, @@ -186,7 +196,11 @@ export async function fetchUsers(params: Record): Promise 0 ? { users, warning: warnings.join('; ') } : { users }; } /** diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index bca3b0bc1..1b159f01f 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -126,6 +126,24 @@ describe('fetchUsers user-type filters', () => { ]); }); + it('stops after a bounded number of pages and says the result may be incomplete', async () => { + // A filter matching few users must not walk every /users page of a large + // instance in one tool call. + nativeUserFilters = false; + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: i, + state: 'active', + bot: false, + })); + for (let i = 0; i < 25; i++) respond(fullPage); + + const result = await fetchUsers({ exclude_active: true }); + + expect(mockEnhancedFetch).toHaveBeenCalledTimes(20); + expect(result.users).toEqual([]); + expect(result.warning).toContain('only the first 2000 users were scanned'); + }); + it('emulates exclude_active on each user state', async () => { nativeUserFilters = false; respond(users); From fe0655bc79ef46e52e09d790ae1d040d87a7000a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:08:04 +0300 Subject: [PATCH 20/25] fix(webhooks): refuse group webhooks on GitLab Free Group webhooks are a Premium feature, but the requirement was declared on create_group/update_group/delete_group, which are not actions of the tool, so it never applied. Both webhook tools now refuse the group scope on Free with a clear reason before calling GitLab; the unused requirement keys are removed. --- .../src/entities/webhooks/registry.ts | 18 ++++++++++--- .../unit/entities/webhooks/registry.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/webhooks/registry.ts b/packages/gitlab-mcp/src/entities/webhooks/registry.ts index 2e5e4c483..5fe4ddcab 100644 --- a/packages/gitlab-mcp/src/entities/webhooks/registry.ts +++ b/packages/gitlab-mcp/src/entities/webhooks/registry.ts @@ -4,7 +4,17 @@ import { ManageWebhookSchema } from './schema'; import { gitlab, toQuery } from '../../utils/gitlab-api'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } from '../utils'; -import { assertInstanceAtLeast } from '../instance-version'; +import { assertInstanceAtLeast, currentInstance } from '../instance-version'; + +/** + * Group webhooks are a Premium feature while project webhooks are Free; one tool + * serves both, so the scope is checked here rather than in tool requirements. + */ +function assertGroupHooksAvailable(scope: 'project' | 'group'): void { + if (scope === 'group' && currentInstance()?.tier === 'free') { + throw new Error('Group webhooks require GitLab Premium'); + } +} /** * Webhooks tools registry - 2 CQRS tools (discriminated union schema) @@ -30,6 +40,7 @@ export const webhooksToolRegistry: ToolRegistry = new Map { @@ -83,11 +94,9 @@ export const webhooksToolRegistry: ToolRegistry = new Map { diff --git a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts index 84fb9b0a3..bb1bafb94 100644 --- a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts @@ -388,6 +388,33 @@ describe('Webhooks Registry', () => { } }); + it('refuses group webhooks on GitLab Free without calling GitLab', async () => { + // Group hooks are a Premium feature; project hooks stay available on Free. + const spy = jest + .spyOn(ConnectionManager.getInstance(), 'getInstanceInfo') + .mockReturnValue({ version: '18.0.0', tier: 'free' } as GitLabInstanceInfo); + try { + await expect( + webhooksToolRegistry.get('manage_webhook')!.handler({ + action: 'create', + scope: 'group', + groupId: 'g', + url: 'https://example.com/hook', + }), + ).rejects.toThrow('Group webhooks require GitLab Premium'); + await expect( + webhooksToolRegistry.get('browse_webhooks')!.handler({ + action: 'list', + scope: 'group', + groupId: 'g', + }), + ).rejects.toThrow('Group webhooks require GitLab Premium'); + expect(mockEnhancedFetch).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + it('should require url for create action', async () => { const tool = webhooksToolRegistry.get('manage_webhook'); expect(tool).toBeDefined(); From 320411881ef582e91e12077ca9e6bcda93405665 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:08:31 +0300 Subject: [PATCH 21/25] test(core): run Duo settings integration tests on disposable fixtures Writing Duo settings on the shared test project and group turned inherited values into explicit overrides for later tests, even when restored. The tests now create their own subgroup and project and delete them afterwards. --- .../schemas-dependent/duo-settings.test.ts | 111 +++++++++--------- 1 file changed, 58 insertions(+), 53 deletions(-) diff --git a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts index dba03b5a3..6118e0585 100644 --- a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts @@ -7,6 +7,10 @@ * invariant instead of a fixed outcome: every requested setting is either * confirmed by an independent read or reported in `not_applied`, and a reported * setting really is unchanged. A silently dropped setting fails the test. + * + * The tests run on a subgroup and project of their own: Duo settings cascade, + * and writing them on the shared fixtures would turn inherited values into + * explicit overrides for every later test. */ import { ManageNamespaceSchema, ManageProjectSchema } from '../../../src/entities/core/schema'; @@ -16,7 +20,7 @@ import { GROUP_DUO_SETTINGS, PROJECT_DUO_SETTINGS } from '../../../src/entities/ import { ConnectionManager } from '../../../src/services/ConnectionManager'; import { getRestrictedParameters } from '../../../src/services/InstanceCapabilities'; import { enhancedFetch } from '../../../src/utils/fetch'; -import { getTestGroup, getTestProject } from '../../setup/testConfig'; +import { getTestGroup } from '../../setup/testConfig'; import { IntegrationTestHelper, initIntegrationHelper } from '../helpers/registry-helper'; type Entity = Record & { @@ -55,20 +59,48 @@ function expectAppliedOrReported( } } -/** The offered settings as they were before the test, for restoring the fixture. */ -function originalValues( - offered: string[], - before: Record, -): Record { - return Object.fromEntries(offered.map((name) => [name, before[name] === true])); -} - describe('GitLab Duo settings - GitLab Integration', () => { let helper: IntegrationTestHelper; + let groupId: string; + let projectId: string; beforeAll(async () => { helper = await initIntegrationHelper(); - }); + const suffix = Date.now().toString(36); + const group = (await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ + action: 'create', + name: `duo-settings-${suffix}`, + path: `duo-settings-${suffix}`, + parent_id: Number(getTestGroup()!.id), + }), + )) as { id: number; full_path: string }; + groupId = String(group.id); + const project = (await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ + action: 'create', + name: `duo-settings-${suffix}`, + namespace: group.full_path, + }), + )) as { id: number }; + projectId = String(project.id); + }, 60000); + + afterAll(async () => { + // Best effort: the run's test group, which contains these, is deleted at the end. + const cleanups: Array<[string, Record]> = []; + if (projectId) cleanups.push(['manage_project', { action: 'delete', project_id: projectId }]); + if (groupId) cleanups.push(['manage_namespace', { action: 'delete', group_id: groupId }]); + for (const [tool, args] of cleanups) { + try { + await helper.executeTool(tool, args); + } catch (error) { + console.warn(`Could not delete Duo settings fixture via ${tool}:`, error); + } + } + }, 60000); it('applies or reports every offered project Duo setting', async () => { const offered = offeredSettings('manage_project', PROJECT_DUO_SETTINGS); @@ -76,7 +108,6 @@ describe('GitLab Duo settings - GitLab Integration', () => { console.log('Instance tier/version offers no project Duo settings - nothing to verify'); return; } - const projectId = String(getTestProject()!.id); const getProject = async () => (await helper.executeTool( 'browse_projects', @@ -87,28 +118,15 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getProject(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - try { - const updated = (await helper.executeTool( - 'manage_project', - ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), - )) as Entity; - console.log( - `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, - ); + const updated = (await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), + )) as Entity; + console.log( + `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); - expectAppliedOrReported(requested, updated, await getProject()); - } finally { - // The project is a shared fixture: later tests must not inherit, say, - // automatic Duo reviews on their merge requests. - await helper.executeTool( - 'manage_project', - ManageProjectSchema.parse({ - action: 'update', - project_id: projectId, - ...originalValues(offered, before), - }), - ); - } + expectAppliedOrReported(requested, updated, await getProject()); }, 60000); it('applies or reports group automatic Duo code review', async () => { @@ -117,7 +135,6 @@ describe('GitLab Duo settings - GitLab Integration', () => { console.log('Instance tier/version offers no group Duo settings - nothing to verify'); return; } - const groupId = String(getTestGroup()!.id); // No tool reads a group's settings (browse_namespaces reads /namespaces), so // the independent read goes to the groups endpoint directly. const getGroup = async () => { @@ -131,26 +148,14 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getGroup(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - try { - const updated = (await helper.executeTool( - 'manage_namespace', - ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), - )) as Entity; - console.log( - `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, - ); + const updated = (await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), + )) as Entity; + console.log( + `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, + ); - expectAppliedOrReported(requested, updated, await getGroup()); - } finally { - // Group settings cascade to every test project; restore them. - await helper.executeTool( - 'manage_namespace', - ManageNamespaceSchema.parse({ - action: 'update', - group_id: groupId, - ...originalValues(offered, before), - }), - ); - } + expectAppliedOrReported(requested, updated, await getGroup()); }, 60000); }); From 6e6f3eda16f928f8b2670b79b89b61c047c5eed9 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:45:09 +0300 Subject: [PATCH 22/25] fix(core): gate the group project listing active filter at GitLab 18.8 GET /groups/:id/projects gained `active` in 18.8, three releases after GET /projects. On 18.5-18.7 the group listing sent `active`, which GitLab ignored, returning projects outside the requested state. The group branch now translates to `archived` below 18.8; the global listing keeps its 18.5 gate. --- .../gitlab-mcp/src/entities/core/registry.ts | 14 ++++++------- .../tests/unit/entities/core/registry.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index dbc6ccd8f..f2c0df9c5 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -201,14 +201,14 @@ export const coreToolRegistry: ToolRegistry = new Map archived=false), which is server-side and therefore // pagination-safe. Projects pending deletion are already hidden from // default listings, so this mapping matches `active` semantics. - const activeFilterSupported = instanceAtLeast('18.5'); - const applyActiveFilter = (value: boolean): void => { - if (activeFilterSupported) { + const applyActiveFilter = (value: boolean, nativeSince: string): void => { + if (instanceAtLeast(nativeSince)) { // active wins over an explicit archived filter: drop archived so the // request never sends both (which would make precedence ambiguous). queryParams.delete('archived'); @@ -223,7 +223,7 @@ export const coreToolRegistry: ToolRegistry = new Map { expect(url).toContain('active=true'); }); + it('translates active for group listings below 18.8, where the group endpoint lacks it', async () => { + // GET /groups/:id/projects gained `active` in 18.8, three releases after + // GET /projects; in between it is ignored, so it must become `archived`. + const url = await browseProjectsListUrlAtVersion( + { group_id: 'my-group', active: true }, + '18.6.0', + ); + expect(url).toContain('/api/v4/groups/my-group/projects?'); + expect(url).toContain('archived=false'); + expect(url).not.toContain('active='); + }); + + it('sends the native active filter to group listings from 18.8', async () => { + const url = await browseProjectsListUrlAtVersion( + { group_id: 'my-group', active: true }, + '18.8.0', + ); + expect(url).toContain('active=true'); + expect(url).not.toContain('archived='); + }); + it('keeps the historical active=true default when active is omitted', async () => { // Default listing on a sub-18.5 instance must NOT switch to the archived // translation; the implicit default is unchanged to avoid a regression. From fbf7c8ad155188e3c03ce02eacbff3d2bf0c9afd Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:45:13 +0300 Subject: [PATCH 23/25] fix(webhooks): reject group-only event fields on project webhooks project_events and subgroup_events exist only on the group hooks API; sent to a project hook they were silently ignored while the call reported success. They are now refused for project scope with a clear reason. --- .../src/entities/webhooks/registry.ts | 8 ++++++++ .../unit/entities/webhooks/registry.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/gitlab-mcp/src/entities/webhooks/registry.ts b/packages/gitlab-mcp/src/entities/webhooks/registry.ts index 5fe4ddcab..df77c23bc 100644 --- a/packages/gitlab-mcp/src/entities/webhooks/registry.ts +++ b/packages/gitlab-mcp/src/entities/webhooks/registry.ts @@ -116,6 +116,14 @@ export const webhooksToolRegistry: ToolRegistry = new Map { diff --git a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts index bb1bafb94..b2ae90a1e 100644 --- a/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/webhooks/registry.test.ts @@ -415,6 +415,23 @@ describe('Webhooks Registry', () => { } }); + it('rejects group-only event fields on project webhooks', async () => { + // The project hooks API has no project_events/subgroup_events and would + // silently ignore them while reporting success. + for (const field of ['project_events', 'subgroup_events']) { + await expect( + webhooksToolRegistry.get('manage_webhook')!.handler({ + action: 'create', + scope: 'project', + projectId: 'p', + url: 'https://example.com/hook', + [field]: true, + }), + ).rejects.toThrow(`${field} applies to group webhooks only`); + } + expect(mockEnhancedFetch).not.toHaveBeenCalled(); + }); + it('should require url for create action', async () => { const tool = webhooksToolRegistry.get('manage_webhook'); expect(tool).toBeDefined(); From e61d26359d4add58316ba9e296f77866a347735a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:45:18 +0300 Subject: [PATCH 24/25] fix(users): keep warnings of every smart search phase Smart search reported only the warning of its last phase, so a first phase that scanned just part of the instance vanished behind an empty transliteration phase. Warnings of all phases run are now kept. --- .../gitlab-mcp/src/utils/smart-user-search.ts | 9 +++++--- .../unit/utils/smart-user-search.test.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/gitlab-mcp/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index e42d5edd5..6c1292ee0 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -242,21 +242,24 @@ export async function smartUserSearch( } // A failed call propagates: an empty result would claim nobody matched. + // Warnings of every phase run are kept: an earlier phase that scanned only + // part of the instance still qualifies an empty final answer. + const warnings = new Set(); const runPhase = async (phase: string, params: UserSearchParams): Promise => { const fetched = await callUsersAPI(params); totalApiCalls++; searchPhases.push({ phase, params, resultCount: fetched.users.length }); + if (fetched.warning) warnings.add(fetched.warning); return fetched; }; - // The returned users come from the last phase run, and so does its warning. - const finish = ({ users, warning }: FetchedUsers): SmartSearchResult => ({ + const finish = ({ users }: FetchedUsers): SmartSearchResult => ({ users, searchMetadata: { query, pattern, searchPhases, totalApiCalls, - ...(warning ? { warning } : {}), + ...(warnings.size > 0 ? { warning: [...warnings].join('; ') } : {}), }, }); diff --git a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts index 1b159f01f..0def38717 100644 --- a/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/smart-user-search.test.ts @@ -422,6 +422,28 @@ describe('smart-user-search utilities', () => { expect(result.searchMetadata.warning).toContain('bot accounts other than project bots'); }); + it('keeps an earlier phase truncation warning when a later phase finds nothing', async () => { + // Otherwise "no users" hides that the first phase scanned only part of + // the instance. + nativeUserFilters = false; + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: i, + state: 'active', + bot: false, + })); + // Targeted username phase: 20 full pages, none inactive. + for (let i = 0; i < 20; i++) { + mockEnhancedFetch.mockResolvedValueOnce(mockApiResponse(fullPage)); + } + // Broad and transliteration phases: empty. + mockEnhancedFetch.mockResolvedValue(mockApiResponse([])); + + const result = await smartUserSearch('иван', { exclude_active: true }); + + expect(result.users).toEqual([]); + expect(result.searchMetadata.warning).toContain('only the first 2000 users were scanned'); + }); + it('drops each default that contradicts the requested exclusion', async () => { // active=true with exclude_active, or humans=true with exclude_humans, // can only ever return nothing. From ca73396e34cc96b58fbe7839f54ec2c7decfff21 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 23 Sep 2026 23:45:23 +0300 Subject: [PATCH 25/25] test(core): validate Duo fixture create and update results --- .../schemas-dependent/duo-settings.test.ts | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts index 6118e0585..e3fbee1e7 100644 --- a/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas-dependent/duo-settings.test.ts @@ -13,6 +13,7 @@ * explicit overrides for every later test. */ +import * as z from 'zod'; import { ManageNamespaceSchema, ManageProjectSchema } from '../../../src/entities/core/schema'; import { BrowseProjectsSchema } from '../../../src/entities/core/schema-readonly'; import { coreToolRegistry } from '../../../src/entities/core/registry'; @@ -23,10 +24,15 @@ import { enhancedFetch } from '../../../src/utils/fetch'; import { getTestGroup } from '../../setup/testConfig'; import { IntegrationTestHelper, initIntegrationHelper } from '../helpers/registry-helper'; -type Entity = Record & { - id: number; - not_applied?: Array<{ setting: string; requested: unknown; current?: unknown }>; -}; +const CreatedGroupSchema = z.looseObject({ id: z.number(), full_path: z.string() }); +const CreatedProjectSchema = z.looseObject({ id: z.number() }); +const UpdatedEntitySchema = z.looseObject({ + id: z.number(), + not_applied: z + .array(z.object({ setting: z.string(), requested: z.unknown(), current: z.unknown() })) + .optional(), +}); +type Entity = z.infer; /** Settings the tool catalog offers on this instance (tier/version gating applied). */ function offeredSettings(toolName: string, tracked: Readonly>): string[] { @@ -67,24 +73,28 @@ describe('GitLab Duo settings - GitLab Integration', () => { beforeAll(async () => { helper = await initIntegrationHelper(); const suffix = Date.now().toString(36); - const group = (await helper.executeTool( - 'manage_namespace', - ManageNamespaceSchema.parse({ - action: 'create', - name: `duo-settings-${suffix}`, - path: `duo-settings-${suffix}`, - parent_id: Number(getTestGroup()!.id), - }), - )) as { id: number; full_path: string }; + const group = CreatedGroupSchema.parse( + await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ + action: 'create', + name: `duo-settings-${suffix}`, + path: `duo-settings-${suffix}`, + parent_id: Number(getTestGroup()!.id), + }), + ), + ); groupId = String(group.id); - const project = (await helper.executeTool( - 'manage_project', - ManageProjectSchema.parse({ - action: 'create', - name: `duo-settings-${suffix}`, - namespace: group.full_path, - }), - )) as { id: number }; + const project = CreatedProjectSchema.parse( + await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ + action: 'create', + name: `duo-settings-${suffix}`, + namespace: group.full_path, + }), + ), + ); projectId = String(project.id); }, 60000); @@ -118,10 +128,12 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getProject(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - const updated = (await helper.executeTool( - 'manage_project', - ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), - )) as Entity; + const updated = UpdatedEntitySchema.parse( + await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ action: 'update', project_id: projectId, ...requested }), + ), + ); console.log( `Project Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, ); @@ -148,10 +160,12 @@ describe('GitLab Duo settings - GitLab Integration', () => { const before = await getGroup(); const requested = Object.fromEntries(offered.map((name) => [name, before[name] !== true])); - const updated = (await helper.executeTool( - 'manage_namespace', - ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), - )) as Entity; + const updated = UpdatedEntitySchema.parse( + await helper.executeTool( + 'manage_namespace', + ManageNamespaceSchema.parse({ action: 'update', group_id: groupId, ...requested }), + ), + ); console.log( `Group Duo settings not applied on this instance: ${JSON.stringify(updated.not_applied ?? [])}`, );