diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 72851463..b4e6ec83 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" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dee68cd5 --- /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 7fb1f398..31c6c3f6 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. Where an older instance lacks newer API +surface, tools fall back to an equivalent it does have; only actions GitLab itself +cannot perform there are hidden (and refused if called), for example: + +| GitLab Version | Work items | +|----------------|------------| +| 18.1+ | All actions, using namespace-level queries | +| 16.4 - 18.0 | All actions; listing goes through project or group queries, as does lookup by IID where the namespace query is missing | +| 16.0 - 16.3 | All actions except `add_link` / `remove_link` | ## Namespace Tier Cache diff --git a/packages/gitlab-mcp/docs/guide/authentication.md b/packages/gitlab-mcp/docs/guide/authentication.md index ba53d095..edbb598b 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 1f243e86..45543c41 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 cf65c078..a2a6cc3d 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 04c9cc49..0200bfd3 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 f9966a02..37275463 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 881abb05..71173cb8 100644 --- a/packages/gitlab-mcp/src/entities/access_tokens/registry.ts +++ b/packages/gitlab-mcp/src/entities/access_tokens/registry.ts @@ -3,12 +3,51 @@ 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. Project access tokens -// landed in 13.0, group access tokens in 14.7; the tool degrades per-action -// rather than gating the whole pair, so the lowest floor is declared here. -const FREE_REQ = { tier: 'free', minVersion: '13.0' } as const; +// Personal/project/group access tokens are Free tier; every endpoint used here +// predates the supported version floor. +const FREE_REQ = { tier: 'free' } as const; + +/** A project/group token list page; `active` drives the client-side state filter. */ +const ListedTokensSchema = z.array(z.looseObject({ active: z.boolean() })); + +/** + * List project/group tokens, honouring `state`. The server-side filter landed in + * GitLab 17.2 (older instances ignore it and return every token), so there it is + * applied client-side on each token's `active` flag, before pagination: GitLab's + * pages are walked until the requested filtered page is complete or the list ends. + */ +async function listScopedTokens( + path: string, + query: { state?: 'active' | 'inactive'; per_page: number; page?: number }, +) { + const { state, per_page: perPage, page = 1 } = query; + if (!state || instanceAtLeast('17.2')) { + return gitlab.get(path, { query: toQuery(query, []) }); + } + const wanted = page * perPage; + const matches: Array[number]> = []; + for (let serverPage = 1; matches.length < wanted; 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); + } + if (batch.length < GITLAB_MAX_PER_PAGE) break; + } + return matches.slice(wanted - perPage, wanted); +} const NEW_TOKEN_NOTICE = 'This response contains a token value shown only once. Store it securely; it cannot be retrieved again.'; @@ -77,16 +116,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 00000000..25265636 --- /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 a94a4598..f2c0df9c 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,28 @@ async function restoreEntity(apiUrl: string): Promise { return restored.data; } +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` + * 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 +96,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). - } - const applyActiveFilter = (value: boolean): void => { - if (activeFilterSupported) { + // default listings, so this mapping matches `active` semantics. + 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'); @@ -213,7 +223,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseNamespacesSchema.parse(args); @@ -356,7 +366,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseCommitsSchema.parse(args); @@ -422,9 +432,11 @@ export const coreToolRegistry: ToolRegistry = new Map ({ ...d, diff: withUnifiedHeaders(d) })); } /* istanbul ignore next -- unreachable with Zod discriminatedUnion */ @@ -453,7 +473,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseEventsSchema.parse(args); @@ -520,7 +540,8 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseUsersSchema.parse(args); @@ -547,22 +568,13 @@ 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; + const { users, warning } = await fetchUsers(params); + // The plain list stays the shape; a filter the instance could only + // partly apply is reported alongside it. + return warning + ? { users: cleanGidsFromObject(users), _warning: warning } + : cleanGidsFromObject(users); } } @@ -595,7 +607,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { const input = BrowseTodosSchema.parse(args); @@ -643,10 +655,10 @@ export const coreToolRegistry: ToolRegistry = new Map => { @@ -816,7 +834,7 @@ export const coreToolRegistry: ToolRegistry = new Map => { @@ -974,7 +999,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 169072fc..05613471 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 --- @@ -148,7 +172,7 @@ const RestoreProjectSchema = z.object({ action: z .literal('restore') .describe( - 'Restore a soft-deleted project within its deletion cooldown window (default 7 days). Fails once the project has been purged. Requires project Owner or instance Administrator.', + 'Restore a soft-deleted project within its deletion cooldown window (default 7 days). Fails once the project has been purged. On GitLab Free needs 18.0+. Requires project Owner or instance Administrator.', ), project_id: requiredId.describe('Project ID or URL-encoded path of the project to restore.'), }); @@ -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 ' + + '18.0+. 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 cedc2c6b..17546a9f 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 c32ff474..18a5eda8 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 4c26dbf3..5b1f8e88 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 00000000..69e38767 --- /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 df392570..8f0f4151 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 8418c80c..ddbe26ed 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 dc14b72b..b7110103 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. 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; /** * CI/CD job token scope tools registry - 2 CQRS tools. @@ -53,7 +53,10 @@ export const jobTokenScopeToolRegistry: ToolRegistry = new Map => { const input = BrowseJobTokenScopeSchema.parse(args); diff --git a/packages/gitlab-mcp/src/entities/labels/registry.ts b/packages/gitlab-mcp/src/entities/labels/registry.ts index 3d8eea68..124363b7 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 37d7f421..c076f305 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 168d188a..3f64a976 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 1047452f..32235de2 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 d58373e0..74b3538d 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 ebcdffb9..4302d9e9 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 f803b18c..35f9fb06 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 37cf9bf0..941c9002 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 3e65d06a..a955b2d1 100644 --- a/packages/gitlab-mcp/src/entities/runners/registry.ts +++ b/packages/gitlab-mcp/src/entities/runners/registry.ts @@ -2,11 +2,12 @@ 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'; -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,104 @@ function applyRunnerSettings( if (src.maintenance_note !== undefined) target.maintenanceNote = src.maintenance_note; } +const RestRunnersSchema = z.array( + z.object({ + id: z.number(), + description: z.string().nullable(), + runner_type: z.string(), + status: z.string().nullable(), + paused: z.boolean(), + }), +); + +interface OwnedRunnerFilters { + type?: string; + status?: string; + paused?: boolean; + tag_list?: string[]; +} + +/** 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: filters.type?.toLowerCase(), + status: filters.status?.toLowerCase(), + paused: filters.paused, + tag_list: filters.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'})`, + ); + } + 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(); + + 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: runners.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, + endCursor: hasNextPage ? 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 +210,7 @@ export const runnersToolRegistry: ToolRegistry = new Map => { const input = BrowseRunnersSchema.parse(args); @@ -127,6 +226,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 91ffd16d..6fdbec25 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 fcda8a4b..bfb52e75 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,11 @@ 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 28fd0916..4f7b7067 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 32c6a72c..df77c23b 100644 --- a/packages/gitlab-mcp/src/entities/webhooks/registry.ts +++ b/packages/gitlab-mcp/src/entities/webhooks/registry.ts @@ -4,6 +4,17 @@ import { ManageWebhookSchema } from './schema'; import { gitlab, toQuery } from '../../utils/gitlab-api'; import { ToolRegistry, EnhancedToolDefinition } from '../../types'; import { assertActionAllowed } from '../utils'; +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) @@ -23,12 +34,13 @@ export const webhooksToolRegistry: ToolRegistry = new Map { const input = BrowseWebhooksSchema.parse(args); assertActionAllowed('browse_webhooks', input.action); + assertGroupHooksAvailable(input.scope); // Helper to determine base API path from scope const getBasePath = (scope: 'project' | 'group', projectId?: string, groupId?: string) => { @@ -82,11 +94,20 @@ export const webhooksToolRegistry: ToolRegistry = new Map { @@ -151,6 +181,7 @@ 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 d830b140..db644210 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,111 @@ 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; + +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', + 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: 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); + +/** + * 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. + */ +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 +381,9 @@ export const workitemsToolRegistry: ToolRegistry = new Map => { const input = BrowseWorkItemsSchema.parse(args); @@ -300,16 +403,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 }; + } + // Checked before anything is created, so a refusal leaves no half-set item. + assertUpdatableWidgets(deferred, 'set'); + // Use comprehensive mutation with widgets support const response = await client.request(CREATE_WORK_ITEM_WITH_WIDGETS, { input: createInput, @@ -593,88 +713,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]) => [ + WIDGET_PROPERTY[widget as UpdateWidgetKey], + { requestedValue: unwrapWidget(requestedValue as object), 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 +1014,8 @@ export const workitemsToolRegistry: ToolRegistry = new Map; + 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 7a5d0225..97198938 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 00000000..851f0bb2 --- /dev/null +++ b/packages/gitlab-mcp/src/graphql/prepare-document.ts @@ -0,0 +1,154 @@ +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); + +/** + * 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 + * 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 ?? typenameOnly(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) ?? + typenameOnly(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 19fbcb95..a325c7a1 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/registry-manager.ts b/packages/gitlab-mcp/src/registry-manager.ts index 6aa1adf1..37550fc2 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/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 9a3ad67e..fdcf066e 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). @@ -260,6 +266,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 = { @@ -630,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; } } @@ -648,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/src/services/InstanceCapabilities.ts b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts index bca332cb..2aca2fc5 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). @@ -136,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. @@ -153,15 +173,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 e3f9a524..cf2820fd 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 c025a992..52d88339 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 049b74d3..061a97e3 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.) @@ -64,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 296e9d00..fcacf8e8 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/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index 64fa218d..6c1292ee 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -1,5 +1,8 @@ +import * as z from 'zod'; import { enhancedFetch } from './fetch'; import { transliterate } from 'transliteration'; +import { instanceAtLeast } from '../entities/instance-version'; +import { GITLAB_DEFAULT_PER_PAGE, GITLAB_MAX_PER_PAGE } from '../entities/utils'; /** * User query type detected by pattern analysis @@ -43,6 +46,8 @@ export interface SmartSearchResult { resultCount: number; }>; totalApiCalls: number; + /** Set when the returned users may not fully match the requested filters. */ + warning?: string; }; } @@ -99,34 +104,116 @@ export function analyzeQuery(query: string): QueryPattern { }; } -/** - * Make GitLab Users API call with given parameters - */ -async function callUsersAPI(params: UserSearchParams): Promise { - const queryParams = new URLSearchParams(); +const ListedUsersSchema = z.array( + z.looseObject({ + state: z.string().optional(), + /** Exposed only in the full user entity (administrators); absent otherwise. */ + bot: z.boolean().optional(), + }), +); - // Add common defaults for better results - const defaultParams = { - active: true, - humans: true, - ...params, - }; +/** Users from GET /users, and why they may not fully match the requested filters. */ +export interface FetchedUsers { + users: unknown[]; + warning?: string; +} - Object.entries(defaultParams).forEach(([key, value]) => { - if (value !== undefined) { - queryParams.set(key, String(value)); - } - }); +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'; - const apiUrl = `${process.env.GITLAB_API_URL}/api/v4/users?${queryParams}`; - const response = await enhancedFetch(apiUrl); +/** 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(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined) queryParams.set(key, String(value)); + }); + 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 parsed = ListedUsersSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error( + `GitLab API error: unexpected users response (${parsed.error.issues[0]?.message ?? 'invalid'})`, + ); + } + return parsed.data; +} + +/** + * 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. 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; + if (instanceAtLeast('17.3') || !(humans || exclude_humans || exclude_active)) { + return { users: await fetchUsersPage(params) }; + } - const users = (await response.json()) as unknown; - return Array.isArray(users) ? (users as unknown[]) : []; + 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; + let truncated = false; + for (let serverPage = 1; matches.length < wanted; serverPage++) { + if (serverPage > MAX_EMULATED_PAGES) { + truncated = true; + break; + } + 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); + const warnings = [ + ...(humans && botFlagMissing ? [PARTIAL_HUMANS_WARNING] : []), + ...(truncated ? [TRUNCATED_WARNING] : []), + ]; + return warnings.length > 0 ? { users, warning: warnings.join('; ') } : { users }; +} + +/** + * Make GitLab Users API call with given parameters + */ +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({ + ...(params.exclude_active ? {} : { active: true }), + ...(params.exclude_humans ? {} : { humans: true }), + ...params, + }); } /** @@ -138,7 +225,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 @@ -155,75 +241,47 @@ export async function smartUserSearch( break; } - try { - users = await callUsersAPI(targetParams); + // 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: `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: '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); - } - - return { + searchPhases.push({ phase, params, resultCount: fetched.users.length }); + if (fetched.warning) warnings.add(fetched.warning); + return fetched; + }; + const finish = ({ users }: FetchedUsers): SmartSearchResult => ({ users, searchMetadata: { query, pattern, searchPhases, totalApiCalls, + ...(warnings.size > 0 ? { warning: [...warnings].join('; ') } : {}), }, - }; + }); + + 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') { + fetched = await runPhase('broad-search', { + search: pattern.originalQuery, + ...additionalParams, + }); + 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) { + fetched = await runPhase('transliteration', { + search: pattern.transliteratedQuery, + ...additionalParams, + }); + } + + return finish(fetched); } diff --git a/packages/gitlab-mcp/src/utils/workItemTypes.ts b/packages/gitlab-mcp/src/utils/workItemTypes.ts index 2113c002..6417e017 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; + +/** 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; + let groupId: string; + let projectId: string; + + beforeAll(async () => { + helper = await initIntegrationHelper(); + const suffix = Date.now().toString(36); + 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 = CreatedProjectSchema.parse( + await helper.executeTool( + 'manage_project', + ManageProjectSchema.parse({ + action: 'create', + name: `duo-settings-${suffix}`, + namespace: group.full_path, + }), + ), + ); + 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); + if (offered.length === 0) { + console.log('Instance tier/version offers no project Duo settings - nothing to verify'); + return; + } + 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 = 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 ?? [])}`, + ); + + 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; + } + // 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 = 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 ?? [])}`, + ); + + expectAppliedOrReported(requested, updated, await getGroup()); + }, 60000); +}); diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index 98d84d43..21366f45 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/cli/list-tools.test.ts b/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts index 58c31f89..8ad9c94b 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 46eb134f..7d7c048d 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,97 @@ 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('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('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([]); + 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 00000000..ede91600 --- /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 87fedad1..8349b3d2 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(), })); @@ -500,6 +502,27 @@ describe('Core Registry', () => { 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. @@ -1185,6 +1208,82 @@ 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('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({ @@ -1581,6 +1680,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({ @@ -2108,6 +2234,29 @@ describe('Core Registry', () => { expect(result).toEqual({ id: 1, marked_for_deletion_on: null }); }); + 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.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 18.0+', + ); + 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 +2417,30 @@ 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 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.11.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 18.0+', ); expect(mockEnhancedFetch).not.toHaveBeenCalled(); + + for (const info of [ + { version: '18.0.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 e181f323..9221b65b 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 ef87a2b0..5004d57e 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/instance-version.test.ts b/packages/gitlab-mcp/tests/unit/entities/instance-version.test.ts new file mode 100644 index 00000000..8408b159 --- /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/job-token-scope/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/job-token-scope/registry.test.ts index d9577813..b1c45e2e 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,10 +42,21 @@ describe('Job Token Scope Registry', () => { expect(getJobTokenScopeToolDefinitions()).toHaveLength(2); }); - 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'); + 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', () => { 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 13fae06d..e2b64ef8 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,132 @@ 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('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('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, + description: 'Deploy runner', + runner_type: 'project_type', + status: 'online', + paused: false, + }, + { + id: 8, + description: 'build', + runner_type: 'project_type', + 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 first = (await browse().handler({ + action: 'list_owned', + search: 'deploy', + })) as { nodes: Array<{ id: number }>; pageInfo: Record }; + + // 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(first.nodes.map((n) => n.id)).toEqual([7]); + expect(first.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 aa62888d..b2ae90a1 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,71 @@ 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('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('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(); 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 00000000..f32bf35f --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/schema-fallbacks.test.ts @@ -0,0 +1,292 @@ +/** + * 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('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'); + 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); + }); + + 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', () => { + 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('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('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: [] } }); + + 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/client.test.ts b/packages/gitlab-mcp/tests/unit/graphql/client.test.ts index 60ba9ac1..f12a4084 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/graphql/prepare-document.test.ts b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts new file mode 100644 index 00000000..76933a72 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/graphql/prepare-document.test.ts @@ -0,0 +1,186 @@ +/** + * 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('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)); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts index 01461098..02144e42 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 }; @@ -110,6 +111,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 +1042,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', () => ({ @@ -1241,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(); diff --git a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts index 3be2c197..67edc9d6 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,41 @@ 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, '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'], + ])('%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 +307,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 +334,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/SchemaIntrospector.test.ts b/packages/gitlab-mcp/tests/unit/services/SchemaIntrospector.test.ts index b96309c4..34d29025 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/services/ToolDescriptionOverrides.test.ts b/packages/gitlab-mcp/tests/unit/services/ToolDescriptionOverrides.test.ts index 8b733023..a081a02a 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', () => ({ diff --git a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts index 78a5a8f2..2d641107 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 b09e9e8b..0def3871 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,130 @@ 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('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('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' }); + await expect(fetchUsers({ humans: true })).rejects.toThrow( + 'GitLab API error: unexpected users response', + ); + }); + + 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 }); + + expect(sentUrl().searchParams.get('humans')).toBeNull(); + expect(sentUrl().searchParams.get('without_project_bots')).toBe('true'); + 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('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); + 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); + 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 () => { + // 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', () => { @@ -258,14 +390,71 @@ 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')); + 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+', + ); + }); + + 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('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.totalApiCalls).toBe(0); - expect(result.searchMetadata.searchPhases).toHaveLength(0); + 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. + 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 () => { diff --git a/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts b/packages/gitlab-mcp/tests/unit/utils/workItemTypes.test.ts index cfcd5d8f..fb617cfc 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', () => {