From 9da7d836032309571551f6cea11892b149d4824c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 1 Nov 2026 23:26:48 +0200 Subject: [PATCH 01/22] test(docs): cover component release tags --- .../tests/unit/release/docs-download.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/gitlab-mcp/tests/unit/release/docs-download.test.ts diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts new file mode 100644 index 00000000..773bf70b --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parse } from 'yaml'; + +// Execute the production workflow step offline: component tags must produce usable asset versions. +test.each(['13.1.0', '13.2.0'])( + 'docs download resolves release %s from its component tag', + (version) => { + const workflow = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/docs.yml'), 'utf8'), + ); + const step = workflow.jobs.build.steps.find( + (candidate: { id?: string }) => candidate.id === 'mcpb', + ); + const root = mkdtempSync(join(tmpdir(), 'gitlab-docs-release-')); + const bin = join(root, 'bin'); + const output = join(root, 'output'); + mkdirSync(bin); + writeFileSync( + join(bin, 'gh'), + `#!${process.execPath} +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +if (args[0] !== 'release') throw new Error('Unexpected GitHub request'); +if (args[1] === 'view') console.log('gitlab-mcp-v${version}'); +else if (args[1] === 'download') { + if (args[2] !== 'gitlab-mcp-v${version}') throw new Error('Wrong release'); + const destination = args[args.indexOf('--dir') + 1]; + fs.writeFileSync(path.join(destination, 'gitlab-mcp-${version}.mcpb'), 'fixture bundle'); +} else throw new Error('Unexpected GitHub command'); +`, + { mode: 0o755 }, + ); + try { + const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', step.run], { + cwd: root, + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_OUTPUT: output }, + encoding: 'utf8', + timeout: 10000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(readFileSync(output, 'utf8')).toBe(`version=${version}\n`); + expect( + readFileSync(join(root, 'docs/public/downloads', `gitlab-mcp-${version}.mcpb`), 'utf8'), + ).toBe('fixture bundle'); + expect(result.stdout).toContain(`MCPB bundle included: ${version}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); From c786a55ba5e7c108c7fd96399f9bdbf83b67ce15 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 10:13:25 +0200 Subject: [PATCH 02/22] fix: align namespace capabilities and setup docs Resolve GitLab.com subscriptions per root namespace and request without treating undisclosed billing metadata as Free. Reuse capability checks for tools and widgets while preserving instance and credential isolation. Correct Roo Code configuration paths, refresh client setup and release documentation, and rebuild documentation after release assets are published. Refs #639 --- .github/workflows/docs.yml | 4 +- TESTING.md | 59 +- .../gitlab-mcp/docs/.vitepress/config.mts | 1 + .../gitlab-mcp/docs/advanced/federation.md | 52 +- .../docs/advanced/tier-detection.md | 261 ++--- .../gitlab-mcp/docs/clients/claude-code.md | 12 +- .../gitlab-mcp/docs/clients/claude-desktop.md | 20 +- .../docs/clients/claude-desktop.md.in | 20 +- packages/gitlab-mcp/docs/clients/cline.md | 7 +- packages/gitlab-mcp/docs/clients/cursor.md | 4 +- packages/gitlab-mcp/docs/clients/index.md | 4 +- packages/gitlab-mcp/docs/clients/roo-code.md | 30 +- packages/gitlab-mcp/docs/clients/vscode.md | 17 +- packages/gitlab-mcp/docs/clients/windsurf.md | 6 +- .../docs/configuration/rate-limiting.md | 14 +- .../docs/deployment/docker-compose.md | 7 +- .../docs/deployment/docker-postgres.md | 7 +- .../docs/deployment/docker-standalone.md | 6 +- .../gitlab-mcp/docs/deployment/local-stdio.md | 6 +- .../docs/guide/installation/claude-desktop.md | 6 +- .../docs/guide/installation/codex.md | 1 + .../docs/guide/installation/docker.md | 6 +- .../docs/guide/installation/manual.md | 6 +- .../gitlab-mcp/docs/guide/multi-instance.md | 18 +- packages/gitlab-mcp/docs/guide/quick-start.md | 2 +- .../docs/guides/codex-qualification.md | 131 +++ .../gitlab-mcp/docs/guides/codex-workflows.md | 5 +- .../gitlab-mcp/docs/guides/duo-sessions.md | 3 + packages/gitlab-mcp/docs/guides/index.md | 1 + packages/gitlab-mcp/docs/index.md | 2 +- packages/gitlab-mcp/docs/security/oauth.md | 51 +- packages/gitlab-mcp/docs/tools/code-review.md | 1 + packages/gitlab-mcp/docs/tools/repository.md | 4 +- packages/gitlab-mcp/src/cli/install/types.ts | 9 +- .../configuration/namespace-capabilities.ts | 111 +++ .../src/configuration/scoped-execution.ts | 17 +- .../gitlab-mcp/src/entities/context/whoami.ts | 2 +- .../src/entities/instance-version.ts | 2 +- .../src/entities/workitems/registry.ts | 8 +- packages/gitlab-mcp/src/handlers.ts | 50 +- packages/gitlab-mcp/src/oauth/types.ts | 6 +- packages/gitlab-mcp/src/registry-manager.ts | 90 +- .../src/services/ConnectionManager.ts | 36 +- .../src/services/GitLabVersionDetector.ts | 4 +- .../src/services/InstanceCapabilities.ts | 18 +- .../src/services/NamespaceTierDetector.ts | 492 ++++------ .../src/services/WidgetAvailability.ts | 26 +- .../src/services/namespace-tier-context.ts | 30 + .../gitlab-mcp/src/utils/error-handler.ts | 10 +- .../tests/unit/RegistryManager.test.ts | 85 +- .../tests/unit/cli/install/roo-code.test.ts | 57 ++ .../namespace-capabilities.test.ts | 110 +++ .../gitlab-mcp/tests/unit/handlers.test.ts | 159 +++ .../tests/unit/release/docs-download.test.ts | 8 + .../unit/services/ConnectionManager.test.ts | 28 + .../ConnectionManagerEnhanced.test.ts | 4 +- .../services/InstanceCapabilities.test.ts | 11 + .../services/NamespaceTierDetector.test.ts | 917 ++++-------------- .../unit/services/WidgetAvailability.test.ts | 16 + .../unit/services/namespace-rest.test.ts | 44 + 60 files changed, 1628 insertions(+), 1496 deletions(-) create mode 100644 packages/gitlab-mcp/docs/guides/codex-qualification.md create mode 100644 packages/gitlab-mcp/src/configuration/namespace-capabilities.ts create mode 100644 packages/gitlab-mcp/src/services/namespace-tier-context.ts create mode 100644 packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f267ba2a..42e2de4e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,7 +9,7 @@ on: - 'yarn.lock' - '.github/workflows/docs.yml' workflow_run: - workflows: ["CI/CD Pipeline"] + workflows: ["Release"] types: [completed] branches: [main] workflow_dispatch: @@ -58,7 +58,7 @@ jobs: mkdir -p docs/public/downloads LATEST_TAG=$(gh release view --json tagName --jq '.tagName' 2>/dev/null || echo "") if [ -n "$LATEST_TAG" ]; then - VERSION="${LATEST_TAG#v}" + VERSION="${LATEST_TAG#gitlab-mcp-v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT gh release download "$LATEST_TAG" --pattern "*.mcpb" --dir docs/public/downloads/ || true MCPB_FILE="docs/public/downloads/gitlab-mcp-${VERSION}.mcpb" diff --git a/TESTING.md b/TESTING.md index be33c2a3..45ddc52f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -22,7 +22,7 @@ The GitLab MCP integration tests follow a **dependency chain pattern** to ensure ## 🚨 Critical Rules 1. **Tests CANNOT run standalone** - They depend on lifecycle data -2. **Must use `--runInBand`** - Tests must run serially to maintain dependencies +2. **Integration suites run serially** - Use the maintained integration scripts to preserve lifecycle order 3. **Single infrastructure creation** - Only data-lifecycle.test.ts creates data 4. **No individual cleanup** - Only final cleanup in data-lifecycle.test.ts 5. **Real data only** - No mocks, all tests use actual GitLab entities @@ -55,14 +55,25 @@ enables `--experimental-vm-modules` so Jest can load jsdom's ESM dependencies fr the CommonJS settings-panel tests. Use `test`, `test:cov`, `test:integration` or `test:all` rather than invoking Jest without that runtime option. -### Complete Integration Test Suite (Recommended) +### Select the required suite + +Run these from `packages/gitlab-mcp`: + +| Command | Executes | External prerequisites | +| --- | --- | --- | +| `yarn test` | Unit tests only | None | +| `yarn test:cov` | Unit tests with coverage | None | +| `yarn test:integration` | Integration suites in lifecycle order | Protected `.env.test`, designated test namespace | +| `yarn test:all` | Unit and integration suites | The same integration environment | + +### Complete Unit and Integration Test Suite ```bash -yarn test +yarn test:all ``` This command: - ✅ Uses all feature flags (WORKITEMS, MILESTONE, PIPELINE, GITLAB_WIKI) -- ✅ Runs tests in dependency order with `--runInBand` +- ✅ Runs suites serially with one worker and lifecycle ordering; accumulated worker memory can be reclaimed between files - ✅ Creates complete infrastructure once - ✅ Tests all schemas with real data - ✅ Cleans up everything at the end @@ -70,10 +81,10 @@ This command: ### Individual Test Files (NOT RECOMMENDED) ```bash # ❌ DON'T DO THIS - Will fail due to missing dependencies -yarn test tests/integration/schemas-dependent/merge-requests.test.ts +yarn test:integration --runTestsByPath tests/integration/schemas-dependent/merge-requests.test.ts # ✅ If you must, run the full chain: -yarn test +yarn test:integration ``` ## 📊 Data Lifecycle Stages @@ -163,7 +174,7 @@ describe('My Schema Tests', () => { ``` Error: Test data not available. Make sure to run data-lifecycle.test.ts first with --runInBand ``` -**Solution:** Use `yarn test` instead of individual test files. +**Solution:** Use `yarn test:integration` instead of individual dependent files. ### Tests running in parallel ``` @@ -186,30 +197,16 @@ Test expects MRs but none found 5. **Clean**: Single cleanup operation at the end 6. **Maintainable**: Clear separation between data creation and testing -## ✅ Current Status (2025-01-15) - -**🎉 ALL CRITICAL ISSUES RESOLVED**: -- ✅ **Complete test suite passing** - 27/27 test suites, 369/372 tests passing (99.2%) -- ✅ **Unit tests completely rewritten** - Proper mock infrastructure using enhancedFetch -- ✅ **Integration tests fully working** - Real GitLab API testing with data lifecycle -- ✅ **Node-fetch migration complete** - All tests now use native fetch API -- ✅ **Test dependency chain FIXED** - Persistent file storage enables data sharing between test files -- ✅ **Soft-fail patterns eliminated** - All tests use real data or fail properly -- ✅ **Jest configuration enhanced** - Proper serial execution with `--runInBand` - -**Test Results Summary**: -- **Total Test Suites**: 27 passed, 0 failed -- **Total Tests**: 369 passed, 3 skipped, 0 failed (372 total) -- **Integration Tests**: 16 suites passing (200+ tests) -- **Unit Tests**: 11 suites passing (169+ tests) -- **Coverage**: 54.92% statements, 37.39% branches, 53.28% functions - -**Key Test Categories**: -- ✅ **Data Lifecycle**: 12/12 tests passing - Complete infrastructure setup/teardown -- ✅ **Schema Validation**: 150+ tests passing - All GitLab API schemas validated -- ✅ **Unit Tests**: 169+ tests passing - Full mock-based handler testing -- ✅ **Work Items GraphQL**: 20+ tests passing - Full CRUD with real GitLab instance -- ✅ **Integration API**: 30+ tests passing - Real GitLab API validation +## Current results and installed-client qualification + +Use the CI results and coverage report for the exact commit under review. Historical +test counts do not establish the state of a current release, and unit-only results +do not establish that integration suites ran. + +The [Codex release qualification procedure](packages/gitlab-mcp/docs/guides/codex-qualification.md) +separates archive/CLI checks, read-only provider checks and manual desktop acceptance. +The [workflow evaluation guide](packages/gitlab-mcp/docs/guides/codex-workflows.md) +describes authenticated client evaluations against deterministic loopback fixtures. ## 🎯 Adding New Tests diff --git a/packages/gitlab-mcp/docs/.vitepress/config.mts b/packages/gitlab-mcp/docs/.vitepress/config.mts index 40f317f1..335ded51 100644 --- a/packages/gitlab-mcp/docs/.vitepress/config.mts +++ b/packages/gitlab-mcp/docs/.vitepress/config.mts @@ -414,6 +414,7 @@ export default defineConfig({ items: [ { text: "Overview", link: "/guides/" }, { text: "Codex and Claude Skills", link: "/guides/codex-workflows" }, + { text: "Codex Release Qualification", link: "/guides/codex-qualification" }, { text: "Duo Sessions", link: "/guides/duo-sessions" }, { text: "Complete Code Review", link: "/guides/complete-code-review" }, { text: "Setup CI Notifications", link: "/guides/setup-ci-notifications" }, diff --git a/packages/gitlab-mcp/docs/advanced/federation.md b/packages/gitlab-mcp/docs/advanced/federation.md index 61b0b931..6411dc88 100644 --- a/packages/gitlab-mcp/docs/advanced/federation.md +++ b/packages/gitlab-mcp/docs/advanced/federation.md @@ -13,6 +13,11 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p ## System Architecture +Namespace-aware capability checks are available from 13.2.0. GitLab.com uses +root-namespace subscriptions; self-managed instances retain their instance +license. See [Tier Detection](/advanced/tier-detection) for visibility and unknown +subscription behavior. + ``` ┌─────────────────────────────────────────────────────────────┐ │ MCP Server (single process) │ @@ -25,7 +30,7 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p │ │ v17.2 SaaS │ v16.8 Self │ v16.0 Self │ │ │ │ Rate: 0/100 │ Rate: 5/50 │ Rate: 2/20 │ │ │ └─────────────┴─────────────┴─────────────┘ │ -│ NOTE: No tier here! Tier is per-namespace, not instance │ +│ SaaS tier: per root namespace; self-managed: per instance │ ├─────────────────────────────────────────────────────────────┤ │ SessionManager (per-user sessions) │ │ ┌──────────────────┐ ┌──────────────────┐ │ @@ -64,12 +69,12 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p │ │ • User info (username, id) ││ │ └─────────────────────────────────────────────────────────────┘│ ├─────────────────────────────────────────────────────────────────┤ -│ NAMESPACE-LEVEL CACHE (per session) — TTL: 5 min │ -│ Tier varies per group/project, especially on gitlab.com │ +│ NAMESPACE CACHE (session, instance, credential): TTL 5 min │ +│ GitLab.com projects inherit their root subscription │ │ ┌─────────────────────────────────────────────────────────────┐│ -│ │ "gitlab-org" → Ultimate, features: {epics: ✓, iterations: ✓}││ -│ │ "my-free-group" → Free, features: {epics: ✗, iterations: ✗} ││ -│ │ "company/proj" → Premium, features: {epics: ✓, ...} ││ +│ │ "enterprise" → Ultimate ││ +│ │ "personal" → Free ││ +│ │ "company" → Unknown when plan metadata is unavailable ││ │ └─────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────┘ ``` @@ -167,31 +172,20 @@ Request flow: ### NamespaceTierDetector -Detects and caches namespace tier via GraphQL: - -```graphql -query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - ... on Group { - plan - } +Observes the REST root namespace's optional `plan` attribute. Numeric project and +group IDs are resolved to paths before looking up their root. Credentials and +instances have separate cache entries, with a five-minute TTL and a total +capacity of 1,024 observations. Missing or unrecognized metadata remains unknown. - ... on Project { - group { - plan - } - } - } -} -``` +Discovery uses the union of the selected roots. Execution uses the actual target +arguments. Both run inside a request-local tier context; `ConnectionManager` +projects that fact into the existing tool and widget capability checks without +mutating its stored instance information. Unknown tiers defer license decisions +to GitLab while preserving version, token and operator restrictions. -Tier normalization: -- `ultimate`, `gold` → Ultimate -- `premium`, `silver`, `bronze`, `starter` → Premium -- Everything else → Free +See [Tier Detection](/advanced/tier-detection) for the REST contract, normalization +and bounded lookup behavior. The cache is disposable and independent on each +replica; it is not an authorization or session store. ### TokenContext diff --git a/packages/gitlab-mcp/docs/advanced/tier-detection.md b/packages/gitlab-mcp/docs/advanced/tier-detection.md index 0c2e3717..2b098a4b 100644 --- a/packages/gitlab-mcp/docs/advanced/tier-detection.md +++ b/packages/gitlab-mcp/docs/advanced/tier-detection.md @@ -1,204 +1,113 @@ --- title: Tier Detection -description: "Understand how GitLab MCP detects namespace tier (Free/Premium/Ultimate) for feature availability. Learn about per-namespace caching, GraphQL queries, and tier-based tool restrictions." +description: "How GitLab MCP combines namespace subscriptions, GitLab versions and permissions when exposing tools and fields." head: - - meta - name: keywords content: tier detection, namespace tier, free, premium, ultimate, feature availability, MCP --- -# GitLab Tier Detection for Premium Features +# GitLab Tier Detection -How GitLab MCP detects the tier (Free/Premium/Ultimate) of each namespace to determine feature availability. +On GitLab.com, subscriptions belong to **root namespaces**. One account can work +with a Free personal namespace, a Premium organization and an Ultimate customer +at the same time. A subgroup or project inherits its root namespace's subscription. +See [GitLab subscription management](https://docs.gitlab.com/subscriptions/manage_subscription/). -## Critical Concept +Self-managed GitLab uses its instance license. Namespace subscription detection +does not replace that existing instance-level path. -::: danger Important -**Tier is per-NAMESPACE, not per-instance!** - -On gitlab.com, a single user can access Free, Premium, and Ultimate namespaces simultaneously. Detecting tier at the instance level would be incorrect. +::: info Available from 13.2.0 +Namespace observations are applied to tool discovery, action requirements and +Work Item widget checks from 13.2.0. Earlier versions use instance-level capability +data for these checks. ::: -## How It Works - -### GraphQL Query - -GitLab MCP queries the namespace tier via GraphQL: - -```graphql -query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - # For groups - check plan directly - ... on Group { - plan - } - - # For projects - check parent group's plan - ... on Project { - group { - plan - } - } - } -} -``` - -### Tier Normalization - -GitLab plan names are normalized to three tiers: - -| GitLab Plan | Normalized Tier | -|-------------|-----------------| -| `ultimate`, `gold` | Ultimate | -| `premium`, `silver`, `bronze`, `starter` | Premium | -| `free`, `null`, other | Free | +## What the client sees -### Caching +With a selected project or group, the advertised tools and fields use that +namespace's observed subscription. A scope covering several roots advertises +their union, so a Free project does not hide tools needed by an Ultimate project. -Namespace tier is cached per-session with 5-minute TTL: +Execution resolves the actual project or group arguments again. Parallel requests +use separate contexts, including when two chats share the same GitLab.com account. +They do not change a shared connection's tier. -``` -Session Cache Key: ${sessionId}:${namespacePath} -Example: abc123:gitlab-org → Ultimate -``` +An unscoped request, an opaque ID that cannot yet be resolved, or a call spanning +different subscriptions has **unknown** tier. Unknown does not mean Free: +discovery does not hide paid features solely because billing metadata is missing. +GitLab still checks the actual operation's license, permissions and feature flags. -Cache is cleared when: -- TTL expires (5 minutes) -- User switches GitLab instance (static token mode) -- Session ends - -## Feature Availability - -Each tier enables different features: - -| Feature | Free | Premium | Ultimate | -|---------|------|---------|----------| -| Issues | Yes | Yes | Yes | -| Merge Requests | Yes | Yes | Yes | -| Wiki | Yes | Yes | Yes | -| Epics | No | Yes | Yes | -| Iterations | No | Yes | Yes | -| Roadmaps | No | Yes | Yes | -| OKRs | No | No | Yes | -| Health Status | No | Yes | Yes | -| Weight | No | Yes | Yes | -| Multi-level Epics | No | Yes | Yes | -| Requirements | No | Yes | Yes | -| Security Dashboard | No | No | Yes | -| Compliance Framework | No | Yes | Yes | - -### Feature Checks in Tools - -Tools that require specific features check the namespace tier: - -```typescript -// Example: Creating an epic -async function handleCreateEpic(args) { - const tierInfo = await getNamespaceTier(args.groupPath); - - if (!tierInfo.features.epics) { - throw new Error( - `Epics require GitLab Premium or Ultimate. ` + - `Namespace "${args.groupPath}" is on ${tierInfo.tier} tier.` - ); - } - - // Proceed with epic creation... -} -``` +Version requirements, token scopes, admin elevation, read-only settings and +operator project restrictions remain independent checks. -### Error Messages +## Subscription lookup -When a feature is unavailable, users receive helpful error messages: - -``` -Error: Epics require GitLab Premium or Ultimate. -Namespace "my-free-group" is on free tier. +The server reads the root entity through REST API v4: -Consider: -- Upgrade the namespace to Premium or Ultimate -- Use a different namespace with higher tier +```http +GET /api/v4/namespaces/example-team ``` -## SaaS vs Self-Hosted - -### GitLab.com (SaaS) - -- Each namespace can have a different tier -- Tier detection via GraphQL is required -- Same user may access Free/Premium/Ultimate namespaces - -### Self-Hosted - -- Typically has a single license for the entire instance -- All namespaces share the same tier -- Tier can be cached at instance level (optimization) - -GitLab MCP detects SaaS vs self-hosted and optimizes accordingly. - -## API Functions - -### getNamespaceTier - -```typescript -async function getNamespaceTier( - namespacePath: string -): Promise - -interface NamespaceTierInfo { - tier: 'free' | 'premium' | 'ultimate'; - features: Record; - cachedAt: Date; -} -``` - -### isFeatureAvailable - -```typescript -async function isFeatureAvailable( - namespacePath: string, - feature: string -): Promise - -// Example usage -const canCreateEpic = await isFeatureAvailable('my-group', 'epics'); -``` - -### clearNamespaceTierCache - -```typescript -function clearNamespaceTierCache(sessionId?: string): void - -// Clear for specific session -clearNamespaceTierCache('abc123'); - -// Clear all caches -clearNamespaceTierCache(); -``` +A numeric project ID is first resolved through `GET /projects/:id`; a numeric +group ID through `GET /groups/:id?with_projects=false`. Numeric namespace +arguments that accept either kind follow the handler's group-first resolution +and try a project only after a group 404. + +The `plan` attribute is permission-dependent. Access to a project does not +guarantee access to its root's billing metadata. Missing attributes, an unknown +plan, inaccessible metadata and lookup failures leave the subscription unknown. +A lookup failure does not turn an otherwise usable GitLab operation into a +license refusal. + +See the [GitLab Namespaces API](https://docs.gitlab.com/api/namespaces/) for +visibility rules. The endpoint and conditional plan attribute already exist in +the [GitLab 16.0 namespace entity](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/lib/ee/api/entities/namespace.rb). + +| Observed plan | Tier | +| --- | --- | +| `free`, `default` | Free | +| `premium`, `premium_trial`, `silver` | Premium | +| `ultimate`, `ultimate_trial`, `gold`, `opensource` | Ultimate | +| Missing, unrecognized or legacy plan without a supported equivalent | Unknown | + +A plan is an input to capability checks, not proof that every feature is enabled. +Individual tools declare their version and tier requirements; widget checks use +the same request-local tier. GitLab's response remains authoritative. + +## Caching and request cost + +Observations, including unknown results, have a five-minute TTL. Cache keys +separate the selected instance, credential, session and root namespace. +Replacing a token or changing account cannot reuse another credential's facts. +The disposable process-local cache has a total capacity of 1,024 entries; it +contains no stored token values. + +Descendants of one root share a lookup. A catalog spanning several roots resolves +at most five distinct targets concurrently and stops starting queued optional +reads after an unknown observation prevents a complete tier decision. Free-only +tool calls do not perform subscription lookups. + +This cache is an optimization, not session or authorization storage. Each server +replica can rebuild its own observations. Refreshing facts never changes another +request's active tier, and an expired request cannot start a tool after a late +metadata response. ## Troubleshooting -### Feature unavailable error - -If you receive a feature unavailability error: - -1. Check the namespace path is correct -2. Verify the namespace has the required tier in GitLab UI -3. Wait 5 minutes for cache to expire if tier was recently upgraded - -### Tier not detected correctly - -If tier detection seems incorrect: - -1. Check GitLab API access - token must have `api` or `read_api` scope -2. Verify the namespace exists and is accessible -3. Check for GraphQL errors in server logs +- Confirm the selected account and project or group in `get_context`. +- Check the namespace subscription in GitLab. A user who can read a project may + still be unable to read its billing metadata. +- If a subscription recently changed, allow up to five minutes for cached facts + to expire. +- An explicit unavailable-action error can also be caused by the GitLab version, + token permissions or a disabled feature. Changing a subscription alone does + not remove those restrictions. +- Inspect logs for metadata status or read failures. Provider response bodies and + parser exception text are excluded from these diagnostics. ## Related Documentation -- [Multi-Instance Setup](/guide/multi-instance) - Getting started guide -- [Federation Architecture](/advanced/federation) - Technical deep dive -- [Context Switching](/advanced/context-switching) - Instance switching behavior +- [Multi-Instance Setup](/guide/multi-instance) +- [Federation Architecture](/advanced/federation) +- [Context Switching](/advanced/context-switching) diff --git a/packages/gitlab-mcp/docs/clients/claude-code.md b/packages/gitlab-mcp/docs/clients/claude-code.md index 5ca7c402..4e755877 100644 --- a/packages/gitlab-mcp/docs/clients/claude-code.md +++ b/packages/gitlab-mcp/docs/clients/claude-code.md @@ -70,13 +70,23 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Alternatively, use Claude Code's native registration and browser sign-in: + +```bash +claude mcp add --transport http gitlab https://mcp.example.com/mcp +``` + +Run `/mcp` in Claude Code to authenticate an OAuth server. Registration defaults +to the current project's local scope; add `--scope user` for all your projects. +See the [official MCP configuration guide](https://code.claude.com/docs/en/mcp). + ## Verification ```bash diff --git a/packages/gitlab-mcp/docs/clients/claude-desktop.md b/packages/gitlab-mcp/docs/clients/claude-desktop.md index 9414fa1e..c2638ef7 100644 --- a/packages/gitlab-mcp/docs/clients/claude-desktop.md +++ b/packages/gitlab-mcp/docs/clients/claude-desktop.md @@ -15,7 +15,7 @@ Configure GitLab MCP for [Claude Desktop](https://claude.ai/download). The easiest way to install — download and open the `.mcpb` bundle: - + Install in Claude Desktop @@ -88,18 +88,14 @@ To run via Docker instead of npx: ## Remote Server -Connect to a running GitLab MCP HTTP server: +Add a remote server through **Customize > Connectors > Add custom connector**, +using its complete MCP endpoint, for example `https://mcp.example.com/mcp`. +Complete OAuth when prompted. -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" - } - } -} -``` +Remote connectors run through Anthropic's cloud and cannot reach a server at your +computer's `localhost`. Keep the local stdio configuration above for local access. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +and [OAuth setup](/security/oauth). ## Verification diff --git a/packages/gitlab-mcp/docs/clients/claude-desktop.md.in b/packages/gitlab-mcp/docs/clients/claude-desktop.md.in index c751e132..0fb568c3 100644 --- a/packages/gitlab-mcp/docs/clients/claude-desktop.md.in +++ b/packages/gitlab-mcp/docs/clients/claude-desktop.md.in @@ -88,18 +88,14 @@ To run via Docker instead of npx: ## Remote Server -Connect to a running GitLab MCP HTTP server: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" - } - } -} -``` +Add a remote server through **Customize > Connectors > Add custom connector**, +using its complete MCP endpoint, for example `https://mcp.example.com/mcp`. +Complete OAuth when prompted. + +Remote connectors run through Anthropic's cloud and cannot reach a server at your +computer's `localhost`. Keep the local stdio configuration above for local access. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +and [OAuth setup](/security/oauth). ## Verification diff --git a/packages/gitlab-mcp/docs/clients/cline.md b/packages/gitlab-mcp/docs/clients/cline.md index fc70bd7a..2f9e9d08 100644 --- a/packages/gitlab-mcp/docs/clients/cline.md +++ b/packages/gitlab-mcp/docs/clients/cline.md @@ -60,13 +60,18 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "streamableHttp", "url": "http://localhost:3333/mcp" } } } ``` +Cline's transport identifier is `streamableHttp`. You can also add the endpoint +through **MCP Servers > Remote Servers**, choosing Streamable HTTP. Use +**Configure MCP Servers** to locate the IDE extension's active configuration file; +the CLI uses a separate configuration. See [Cline's MCP guide](https://docs.cline.bot/mcp/mcp-overview). + ## Verification 1. Open VS Code with the Cline extension installed diff --git a/packages/gitlab-mcp/docs/clients/cursor.md b/packages/gitlab-mcp/docs/clients/cursor.md index b257c330..a69008df 100644 --- a/packages/gitlab-mcp/docs/clients/cursor.md +++ b/packages/gitlab-mcp/docs/clients/cursor.md @@ -60,13 +60,15 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", "url": "http://localhost:3333/mcp" } } } ``` +Cursor infers the remote transport from its URL. See its +[MCP configuration guide](https://cursor.com/docs/mcp). + ## Verification 1. Open Cursor diff --git a/packages/gitlab-mcp/docs/clients/index.md b/packages/gitlab-mcp/docs/clients/index.md index 4099ef40..f0bfb11b 100644 --- a/packages/gitlab-mcp/docs/clients/index.md +++ b/packages/gitlab-mcp/docs/clients/index.md @@ -25,7 +25,9 @@ GitLab MCP works with all major AI coding assistants that support the Model Cont | [Codex](/guide/installation/codex) | stdio or HTTP | CLI command | Plugin or `codex mcp` | | [Antigravity CLI](/clients/antigravity) | stdio or HTTP | No | Workspace JSON | -All clients can also connect via HTTP (StreamableHTTP or SSE) to a [remote server deployment](/deployment/). +Remote HTTP support and configuration syntax depend on the client. Follow its +specific guide when connecting to a [remote server deployment](/deployment/); +a stdio-only configuration may need a supported HTTP-to-stdio adapter. ## Automatic Setup diff --git a/packages/gitlab-mcp/docs/clients/roo-code.md b/packages/gitlab-mcp/docs/clients/roo-code.md index 87095cad..94066615 100644 --- a/packages/gitlab-mcp/docs/clients/roo-code.md +++ b/packages/gitlab-mcp/docs/clients/roo-code.md @@ -9,33 +9,45 @@ head: # Roo Code Integration Guide -Configure GitLab MCP for [Roo Code](https://roocode.com). +Configure GitLab MCP for the Roo Code extension. Its +[MCP configuration reference](https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo/) +describes the project and global configuration formats. -## Auto-Setup +## Open the active configuration + +In Roo Code's MCP settings, choose **Edit Project MCP** for the workspace or +**Edit Global MCP** for all workspaces. Project configuration overrides global +configuration for the same server name. + +For the standard VS Code profile, automatic setup writes the extension's global +`settings/mcp_settings.json`: ```bash npx @structured-world/gitlab-mcp install --roo-code ``` +With a custom VS Code profile, another editor or Roo's custom storage path, open +**Edit Global MCP** to find and edit the active configuration. The installer does +not migrate the unused home-level `~/.roo/mcp.json` file. + ## Detection | Method | Details | |--------|---------| | Type | Config directory | -| Path | `~/.roo/` | -| Auto-detected | Yes (when config directory exists) | +| Project path | `.roo/` in the workspace | +| Global configuration | Open with **Edit Global MCP** | ## Config File Location | OS | Path | |----|------| -| macOS | `~/.roo/mcp.json` | -| Windows | `%USERPROFILE%\.roo\mcp.json` | -| Linux | `~/.roo/mcp.json` | +| All platforms, project | `.roo/mcp.json` in the workspace root | +| All platforms, global | `mcp_settings.json` opened by Roo Code | ## Configuration -Add to your `~/.roo/mcp.json`: +Add to the configuration file opened by Roo Code: ```json { @@ -76,7 +88,7 @@ Connect to a running GitLab MCP HTTP server: ## Troubleshooting -- **Config directory missing**: Create `~/.roo/` manually if it doesn't exist +- **Config directory missing**: Use **Edit Project MCP** to create the workspace configuration - **Server not appearing**: Restart Roo Code after editing `mcp.json` - **JSON syntax errors**: Validate the file with a JSON linter diff --git a/packages/gitlab-mcp/docs/clients/vscode.md b/packages/gitlab-mcp/docs/clients/vscode.md index 4be030a4..1963ebfc 100644 --- a/packages/gitlab-mcp/docs/clients/vscode.md +++ b/packages/gitlab-mcp/docs/clients/vscode.md @@ -38,7 +38,8 @@ Install directly from VS Code: | All | `.vscode/mcp.json` (per-project) | ::: tip -VS Code uses per-project MCP configuration. Create the config in each project's `.vscode/` directory. +Use `.vscode/mcp.json` for workspace configuration. VS Code also supports user-level +MCP configuration through its MCP configuration commands. ::: ## Configuration @@ -47,8 +48,9 @@ Create or edit `.vscode/mcp.json` in your project root: ```json { - "mcpServers": { + "servers": { "gitlab": { + "type": "stdio", "command": "npx", "args": ["-y", "@structured-world/gitlab-mcp"], "env": { @@ -66,15 +68,20 @@ Connect to a running GitLab MCP HTTP server: ```json { - "mcpServers": { + "servers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +These examples use the VS Code `.vscode/mcp.json` format with a `servers` root. +Portable Copilot configurations use `mcpServers` instead; do not interchange the +formats when selecting a different configuration destination. +See [Microsoft's MCP server guide](https://code.visualstudio.com/docs/agent-customization/mcp-servers). + ## Verification 1. Open the project in VS Code @@ -85,7 +92,7 @@ Connect to a running GitLab MCP HTTP server: ## Troubleshooting - **Tools not appearing**: Reload the VS Code window (`Cmd+Shift+P` > "Reload Window") -- **Per-project vs global**: VS Code MCP is per-project; ensure `.vscode/mcp.json` is in your workspace root +- **Configuration scope**: Check whether the server is registered for your workspace or user profile - **Copilot required**: MCP support requires GitHub Copilot extension ## See Also diff --git a/packages/gitlab-mcp/docs/clients/windsurf.md b/packages/gitlab-mcp/docs/clients/windsurf.md index d6a534b8..91a42694 100644 --- a/packages/gitlab-mcp/docs/clients/windsurf.md +++ b/packages/gitlab-mcp/docs/clients/windsurf.md @@ -60,13 +60,15 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" + "serverUrl": "http://localhost:3333/mcp" } } } ``` +Windsurf uses `serverUrl` for remote MCP endpoints. Open its MCP configuration +from Cascade to confirm the active file for your installed version. + ## Verification 1. Open Windsurf diff --git a/packages/gitlab-mcp/docs/configuration/rate-limiting.md b/packages/gitlab-mcp/docs/configuration/rate-limiting.md index ad01be9e..45a0eae7 100644 --- a/packages/gitlab-mcp/docs/configuration/rate-limiting.md +++ b/packages/gitlab-mcp/docs/configuration/rate-limiting.md @@ -13,11 +13,17 @@ Configure per-instance rate limiting to prevent overwhelming GitLab instances wi ## Overview -GitLab MCP implements per-instance rate limiting to: -- Prevent hitting GitLab API rate limits +GitLab MCP implements a shared per-instance concurrency queue to: +- Bound simultaneous outbound API requests - Ensure fair resource usage in multi-user environments - Protect self-hosted instances from overload +This queue does not impose a requests-per-minute quota and cannot guarantee that +GitLab will not return 429. It is separate from the inbound MCP/OAuth request limits +described in [OAuth request rate limits](/security/oauth#request-rate-limits). +GitLab response errors expose retry guidance, including `Retry-After` when supplied; +the server does not automatically replay a write. + ## Configuration ### Per-Instance Settings @@ -101,7 +107,9 @@ rateLimit: queueTimeout: 60000 ``` -GitLab.com can handle high concurrency but has its own rate limits (2000 requests/minute for authenticated users). +GitLab.com has its own endpoint- and account-specific rate limits. Check the +[current GitLab.com limits](https://docs.gitlab.com/user/gitlab_com/#gitlabcom-specific-rate-limits) +and reduce concurrency when the server returns 429. ### Self-Hosted (Production) diff --git a/packages/gitlab-mcp/docs/deployment/docker-compose.md b/packages/gitlab-mcp/docs/deployment/docker-compose.md index 15a3fad1..9ecbcc98 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-compose.md +++ b/packages/gitlab-mcp/docs/deployment/docker-compose.md @@ -47,11 +47,13 @@ Clients connect to `OAUTH_ISSUER` (from the `.env` file below) with `/mcp` appen discovery metadata names that URL as the resource, so a client using any other address rejects the server or receives tokens for a different resource. +For example, Claude Code uses this HTTP configuration: + ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "https://mcp.example.com/mcp" } } @@ -61,6 +63,9 @@ rejects the server or receives tokens for a different resource. For local testing set `OAUTH_ISSUER=http://localhost:3333` and connect to `http://localhost:3333/mcp`. +Other clients use different configuration formats. Follow the +[client-specific instructions](/clients/). + ## docker-compose.yml ```yaml diff --git a/packages/gitlab-mcp/docs/deployment/docker-postgres.md b/packages/gitlab-mcp/docs/deployment/docker-postgres.md index 22a66b2f..0fc0753e 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-postgres.md +++ b/packages/gitlab-mcp/docs/deployment/docker-postgres.md @@ -69,11 +69,13 @@ docker run -d --name gitlab-mcp \ Clients connect to `OAUTH_ISSUER` with `/mcp` appended; the discovery metadata names that URL as the resource, so any other address fails discovery. +For example, Claude Code uses this HTTP configuration: + ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "https://mcp.example.com/mcp" } } @@ -83,6 +85,9 @@ URL as the resource, so any other address fails discovery. For local testing set `OAUTH_ISSUER=http://localhost:3333` and connect to `http://localhost:3333/mcp`. +Other clients use different configuration formats. Follow the +[client-specific instructions](/clients/). + ## Environment Variables | Variable | Required | Description | diff --git a/packages/gitlab-mcp/docs/deployment/docker-standalone.md b/packages/gitlab-mcp/docs/deployment/docker-standalone.md index 098e7e77..a74a16d1 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-standalone.md +++ b/packages/gitlab-mcp/docs/deployment/docker-standalone.md @@ -39,19 +39,21 @@ Select "standalone" when prompted for deployment type. ## Client Configuration -### StreamableHTTP (Recommended) +### Claude Code over HTTP (Recommended) ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +For other clients, follow the corresponding [client guide](/clients/). + ### SSE (Legacy) ```json diff --git a/packages/gitlab-mcp/docs/deployment/local-stdio.md b/packages/gitlab-mcp/docs/deployment/local-stdio.md index 356a50cf..9feb88f0 100644 --- a/packages/gitlab-mcp/docs/deployment/local-stdio.md +++ b/packages/gitlab-mcp/docs/deployment/local-stdio.md @@ -9,7 +9,9 @@ head: # Local stdio Deployment -Run GitLab MCP locally via standard I/O. The MCP client spawns the server process directly — no network, no ports, no containers. +Run GitLab MCP locally via standard I/O. The MCP client spawns the server process +directly, without a local HTTP listener or container. The server still needs network +access to GitLab. ## When to Use @@ -79,7 +81,7 @@ See [Configuration reference](/guide/configuration) for all options. ## Limitations -- One instance per MCP client +- One server process per MCP client; that process can use several configured GitLab instances - No shared access between users - No OAuth support (token-based only) - Process lifecycle tied to the MCP client diff --git a/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md b/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md index 6ef9ce8d..2d003ec6 100644 --- a/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md +++ b/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md @@ -15,8 +15,8 @@ Install GitLab MCP as a Claude Desktop Extension with one click. Download the latest bundle and open it: - - Download gitlab-mcp-13.0.0.mcpb + + Download gitlab-mcp-13.1.0.mcpb Or drag-and-drop the `.mcpb` file into your Claude Desktop window. @@ -45,7 +45,7 @@ If you prefer manual setup, use the [npm method](/guide/installation/npm) or [JS ## Updating When a new version is released, download the latest `.mcpb` from the -documentation site or the +documentation site or the [GitHub releases page](https://github.com/structured-world/gitlab-mcp/releases). ## Advanced Configuration diff --git a/packages/gitlab-mcp/docs/guide/installation/codex.md b/packages/gitlab-mcp/docs/guide/installation/codex.md index 46a2f936..4cf0e8b4 100644 --- a/packages/gitlab-mcp/docs/guide/installation/codex.md +++ b/packages/gitlab-mcp/docs/guide/installation/codex.md @@ -182,3 +182,4 @@ GITLAB_TOKEN = "glpat-..." - [`gitlab-mcp install`](/cli/install) - [Codex workflows](/guides/codex-workflows) +- [Release qualification and desktop acceptance](/guides/codex-qualification) diff --git a/packages/gitlab-mcp/docs/guide/installation/docker.md b/packages/gitlab-mcp/docs/guide/installation/docker.md index a3a281da..8f999586 100644 --- a/packages/gitlab-mcp/docs/guide/installation/docker.md +++ b/packages/gitlab-mcp/docs/guide/installation/docker.md @@ -86,19 +86,21 @@ docker run -i --rm \ ghcr.io/structured-world/gitlab-mcp:latest ``` -### StreamableHTTP Client (Recommended) +### Claude Code over HTTP (Recommended) ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Other clients have [their own configuration formats](/clients/). + ### SSE Client (Legacy) ```json diff --git a/packages/gitlab-mcp/docs/guide/installation/manual.md b/packages/gitlab-mcp/docs/guide/installation/manual.md index eebf3c74..7bd8145c 100644 --- a/packages/gitlab-mcp/docs/guide/installation/manual.md +++ b/packages/gitlab-mcp/docs/guide/installation/manual.md @@ -120,19 +120,21 @@ For containerized execution: ## Alternative: HTTP/SSE Remote Server -For connecting to a running GitLab MCP server: +For connecting Claude Code to a running GitLab MCP server: ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Other clients use different configuration formats. See the [client guides](/clients/). + ## Verifying Configuration Use the `install --show` command to preview the generated configuration for any client: diff --git a/packages/gitlab-mcp/docs/guide/multi-instance.md b/packages/gitlab-mcp/docs/guide/multi-instance.md index 711d5a9e..ad09ed34 100644 --- a/packages/gitlab-mcp/docs/guide/multi-instance.md +++ b/packages/gitlab-mcp/docs/guide/multi-instance.md @@ -88,15 +88,19 @@ Each entry shows the instance address, below its label when one is set; choosing **Tier is per-NAMESPACE, not per-instance!** On gitlab.com, a single user can access Free, Premium, and Ultimate namespaces simultaneously. ::: -GitLab MCP detects tier per namespace via GraphQL: -- Tier is cached per-session with 5-minute TTL -- Different namespaces may have different tiers -- Features are validated against namespace tier +From 13.2.0, GitLab.com capability checks use the selected root namespace's REST +subscription metadata. Descendants inherit the root's subscription. The same +account can use different tiers concurrently without changing shared connection +data. Self-managed instances continue to use their instance license. + +Observations are cached per credential, instance and session for five minutes. +If GitLab does not expose the plan, it remains unknown rather than becoming Free. +Discovery then keeps paid features visible and GitLab checks the actual request. Example: User has access to: -- `my-free-group` (Free tier) - no epics -- `company/premium-proj` (Premium tier) - epics available -- `enterprise-client` (Ultimate tier) - all features +- `my-free-group` has a Free root subscription +- `company/premium-proj` inherits the Premium subscription of `company` +- `enterprise-client` has an Ultimate root subscription See [Tier Detection](/advanced/tier-detection) for technical details. diff --git a/packages/gitlab-mcp/docs/guide/quick-start.md b/packages/gitlab-mcp/docs/guide/quick-start.md index e35d93c8..d8ff0580 100644 --- a/packages/gitlab-mcp/docs/guide/quick-start.md +++ b/packages/gitlab-mcp/docs/guide/quick-start.md @@ -12,7 +12,7 @@ howto: - name: "Get a GitLab Token" text: "Create a Personal Access Token in GitLab with 'api' and 'read_user' scopes. Go to GitLab Settings > Access Tokens, create a new token with the required scopes, and copy it." - name: "Configure Your MCP Client" - text: "Add the gitlab-mcp server configuration to your MCP client (Claude Desktop, VS Code, Cursor, etc.). Set GITLAB_TOKEN to your token and GITLAB_API_URL to your GitLab instance URL." + text: "Follow your client's configuration guide. The JSON example below is for Claude Desktop and Cursor; VS Code uses a different format. Set GITLAB_TOKEN to your token and GITLAB_API_URL to your GitLab instance URL." - name: "Start Using GitLab MCP" text: "The server starts automatically when your MCP client connects. All default tools are enabled. You can now ask your AI agent to interact with GitLab." --- diff --git a/packages/gitlab-mcp/docs/guides/codex-qualification.md b/packages/gitlab-mcp/docs/guides/codex-qualification.md new file mode 100644 index 00000000..cae7e92d --- /dev/null +++ b/packages/gitlab-mcp/docs/guides/codex-qualification.md @@ -0,0 +1,131 @@ +--- +title: Codex Release Qualification +description: Verify a released GitLab MCP plugin in Codex desktop and Claude Code without confusing protocol checks with desktop acceptance. +--- + +# Verify a released Codex plugin + +Use the [installation guide](/guide/installation/codex) for setup. This procedure +checks the installed release, both connection modes and the boundaries between +clients. A passing command-line check does not establish that desktop installation, +browser sign-in or native settings work. + +## Prerequisites and evidence + +Use the ZIP and checksum from the same GitHub release. Record the release tag, +ZIP SHA-256, operating system, Node version, desktop app version and Codex CLI +version. The configurable plugin requires Node.js 24 or later, including when +connecting to an existing HTTP server. The release ZIP includes its production +dependencies; the repository marketplace downloads the pinned npm runtime instead. + +Use only a designated test project. Keep tokens in credential fields and test +configuration, never in prompts or screenshots. Remove private URLs, account names +and project data from screenshots before sharing them. Do not execute Duo flows +or consume credits as part of qualification. + +For each check record `verified`, `failed`, or `not run or blocked`, with the +observed result. Keep desktop evidence separate from automated evidence. Record +the backend release as well as the plugin release when testing a remote server. + +### Recorded automated check + +On 2026-11-01, release `gitlab-mcp-v13.1.0` passed the package and native CLI +lifecycle checks below on macOS 27.0.1 with Node.js 26.8.1 and Codex CLI +`0.166.0-alpha.5.1`. Its ZIP SHA-256 was +`fcbd89cdfc70bf073981c0fbbda431f8508539cc896a2e769d1d306cc1e5692d`. + +All 28 connection/authentication contracts passed. The separate live read-only +check returned the same account through local stdio and loopback HTTP. +Desktop UI acceptance and an independent production HTTP deployment were not +established by these checks. Repeat qualification for the next published artifact; +these results do not qualify the unreleased 13.2.0 package. + +## Automated package and lifecycle checks + +From `packages/gitlab-mcp`, after installing workspace dependencies: + +```bash +yarn build +yarn qualify:codex-plugin --archive /path/to/gitlab-mcp-codex.zip --codex /path/to/codex +``` + +Place `gitlab-mcp-codex.zip.sha256` beside the ZIP. This verifies the checksum, +extracts the actual artifact, runs its connection/authentication contracts, and +uses the real Codex CLI in a temporary home for install, disable, enable, upgrade, +reinstall and uninstall. Unrelated Codex settings and seeded Claude configuration +must remain unchanged. The simulated upgrade stays inside that temporary home; +it does not publish a release or modify the input ZIP. + +Optional read-only provider qualification uses the existing protected test configuration: + +```bash +node --env-file=.env.test scripts/qualify-codex-plugin.mjs --archive /path/to/gitlab-mcp-codex.zip --live +``` + +It reads the GitLab profile through the installed runtime in local mode and through +a temporary loopback HTTP core, checks that the account agrees, then disconnects. +It does not verify an operator's production OAuth deployment or a desktop UI. + +CI already checks the built archive, connection contracts, installed CLI lifecycle +and generated repository marketplace. Authenticated skill evaluations are separate: +see [workflow qualification](/guides/codex-workflows#verify-the-installed-workflows). + +## Desktop acceptance + +Follow the installation guide with a released archive. Use the app's Plugins +Directory to confirm installation, not a manually added MCP server alone. + +1. **Installation:** confirm the plugin's name and release version. Check that no + old standalone registration hides its server or duplicates tools. Open a fresh + chat with the installed plugin enabled. +2. **Unconfigured startup:** open plugin settings and choose **Set up, change or + reconnect GitLab**. The connection panel must open before a backend is configured. +3. **Local mode:** choose **GitLab instance, run MCP locally**, enter the test GitLab + address and token in the panel, and connect. Verify the displayed account and + instance. Ask to identify the account, find the test project and read its README. +4. **Remote mode:** choose **Existing GitLab MCP server** and enter the test endpoint. + For OAuth, continue browser sign-in, authorize, then refresh status. Verify the + account and repeat the project read. Record denied sign-in separately: it must + not replace a previously saved connection or expose provider response bodies. +5. **Backend settings:** inspect account defaults, choose a project scope for this + chat, and enable read-only mode. Read the chosen project. A requested mutation + must be refused before it changes GitLab. Reset the chat and verify that account + defaults are restored. Use only disposable test data for authorized write checks. +6. **Normal work:** inspect a test MR and its discussions, read a work item and + inspect a failed pipeline with bounded job logs. Check that the selected project + and account stay correct throughout. Do not repeat an uncertain write. +7. **Recovery:** cancel an unfinished replacement, reconnect the saved connection, + restart the app and read the project again. Test invalid credentials and + insufficient scopes with dedicated test credentials. Use the + [connection diagnostics](/guides/codex-workflows#structured-diagnostics-and-failures) + and restore the working connection through the panel. +8. **Two clients:** keep Codex and Claude Code connected to the same backend. + Select a different chat scope or linked account in one session and verify that + the other keeps its own selection. Verify standalone Claude stdio separately. + Account defaults deliberately apply across clients using that account unless a + chat overrides them; connection-panel selection is shared by this user's local + plugin processes. These shared settings are not isolation failures. +9. **Lifecycle:** install the next released version and reconnect. Check identity + and settings again. Disable the plugin, then enable it and repeat a read. Before + uninstalling, disconnect if saved credentials should be erased. Remove the + plugin and confirm that its tools disappear while Claude configuration remains. + +Capture the plugin/version screen, connected panel, backend settings and one +successful test-project read. Do not manufacture desktop screenshots from a browser +preview or treat the CLI lifecycle as desktop acceptance. + +## Surface and runtime boundaries + +| Surface | What to verify | Boundary | +| --- | --- | --- | +| Codex desktop, configurable plugin | Both connection modes, panel, settings, real reads, lifecycle | Requires Node.js 24+ and local access to the selected backend | +| Codex CLI, installed plugin | Artifact installation, MCP discovery and lifecycle | Does not prove desktop panel rendering or browser sign-in | +| Codex standalone MCP | Registration and normal GitLab tools | Does not install the configurable plugin | +| Claude Code | Existing stdio/HTTP setup and normal workflows | Claude Channels remain specific to Claude Code | +| Claude Desktop MCPB | Install its separate release bundle and exercise MCP | Codex ZIP and MCPB are different artifacts | +| Codex cloud or ChatGPT web | Qualify independently if advertised | A local stdio plugin is desktop-only | + +Run deterministic protocol tests for GitLab 16.0 separately from a current real +test instance. For replicated remote deployments, verify session storage and +account isolation on the shared backend; a single local process is not an HA test. +No platform or surface is verified merely because another row passed. diff --git a/packages/gitlab-mcp/docs/guides/codex-workflows.md b/packages/gitlab-mcp/docs/guides/codex-workflows.md index 153f6e4f..30fa17da 100644 --- a/packages/gitlab-mcp/docs/guides/codex-workflows.md +++ b/packages/gitlab-mcp/docs/guides/codex-workflows.md @@ -5,7 +5,7 @@ description: Portable GitLab workflows, independent client schemas, structured d # Codex and Claude workflow skills -The npm package includes five portable skills under `skills/`. They use the existing +The npm package includes portable skills under `skills/`. They use the existing MCP tools and work with a configured Codex or Claude Code connection: | Skill | Workflow | @@ -118,7 +118,8 @@ loads its skills into a temporary workspace/session-only plugin, and runs the actual built server against a deterministic loopback GitLab fixture. It does not use live GitLab credentials or modify global client configuration. -The matrix covers all five direct workflows, indirect discovery/review, a retained +The evaluation matrix covers setup, discovery, review, work items and CI, indirect +discovery/review, a retained conversation follow-up, an unrelated request and a denied mutation. Assertions inspect actual tool calls, prohibit unintended writes/replays, and retain client versions, transcripts and fixture requests in the printed temporary directory. diff --git a/packages/gitlab-mcp/docs/guides/duo-sessions.md b/packages/gitlab-mcp/docs/guides/duo-sessions.md index bcf343bb..72d4b73f 100644 --- a/packages/gitlab-mcp/docs/guides/duo-sessions.md +++ b/packages/gitlab-mcp/docs/guides/duo-sessions.md @@ -5,6 +5,9 @@ description: Discover and inspect asynchronous GitLab Duo sessions, and explicit # GitLab Duo sessions +Available from GitLab MCP **13.2.0**. Use that release or later for these tools; +the earlier 13.1.0 plugin archive does not include them. + `browse_duo_sessions` discovers agents and flows, lists sessions and reads their progress. `manage_duo_session` starts a CI session or answers a checkpoint. GitLab owns execution, authorization, history and billing. The MCP server adds diff --git a/packages/gitlab-mcp/docs/guides/index.md b/packages/gitlab-mcp/docs/guides/index.md index c6ed812c..2534c6ee 100644 --- a/packages/gitlab-mcp/docs/guides/index.md +++ b/packages/gitlab-mcp/docs/guides/index.md @@ -16,6 +16,7 @@ Comprehensive walkthroughs for common GitLab MCP workflows. | Guide | Description | Key Tools | |-------|-------------|-----------| | [Codex and Claude Workflow Skills](/guides/codex-workflows) | Portable workflows and shared MCP contracts | manage_context, browse_projects, browse_merge_requests, browse_work_items, browse_pipelines | +| [Codex Release Qualification](/guides/codex-qualification) | Released artifact checks and desktop acceptance | Installed plugin, connection panel, account settings | | [Complete Code Review](/guides/complete-code-review) | End-to-end MR review process | browse_merge_requests, manage_mr_discussion | | [Setup CI Notifications](/guides/setup-ci-notifications) | Pipeline alerts via Slack/Discord/webhooks | manage_webhook, manage_integration | | [Automate Releases](/guides/automate-releases) | Release workflow with changelogs | manage_release, browse_commits | diff --git a/packages/gitlab-mcp/docs/index.md b/packages/gitlab-mcp/docs/index.md index 8a3c6bb1..fce8be74 100644 --- a/packages/gitlab-mcp/docs/index.md +++ b/packages/gitlab-mcp/docs/index.md @@ -44,7 +44,7 @@ features: ## One-Click Install -Install in Claude Desktop +Install in Claude Desktop [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) diff --git a/packages/gitlab-mcp/docs/security/oauth.md b/packages/gitlab-mcp/docs/security/oauth.md index 2d15a43c..b998db35 100644 --- a/packages/gitlab-mcp/docs/security/oauth.md +++ b/packages/gitlab-mcp/docs/security/oauth.md @@ -99,53 +99,26 @@ Use a reverse proxy (nginx, Caddy, Traefik) to add HTTPS. See [TLS/HTTPS Configu ## Claude Web Setup 1. Go to [claude.ai](https://claude.ai) and sign in -2. Navigate to **Settings > Connectors** +2. Navigate to **Customize > Connectors** 3. Click **Add custom connector** -4. Enter your gitlab-mcp server URL: `https://your-mcp-server.com` +4. Enter your MCP endpoint: `https://mcp.example.com/mcp` 5. Click **Add** 6. When prompted, complete authentication: - - You'll see a device code (e.g., `ABCD-1234`) - - Open your GitLab instance and enter the code - - Approve the authorization request + - Follow the browser redirect to your GitLab instance + - Sign in and approve the authorization request + - Return to Claude when authorization completes 7. The connector is now active ## Claude Desktop Setup -### macOS / Linux +For a remote OAuth server, add a custom connector using the same connector UI as +Claude Web. These connections originate from Anthropic's cloud, including when +configured in Claude Desktop, so the MCP endpoint must be publicly reachable. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). -Edit `~/.config/claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "https://your-mcp-server.com/mcp" - } - } -} -``` - -### Windows - -Edit `%APPDATA%\Claude\claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "https://your-mcp-server.com/mcp" - } - } -} -``` - -After adding the server: -1. Restart Claude Desktop -2. Claude will prompt you to authenticate -3. Complete the device flow authorization in GitLab -4. Start using GitLab tools with your personal identity +For local access, use a desktop extension or stdio configuration instead. +See [Claude Desktop integration](/clients/claude-desktop). An HTTP `url` entry is +not a local stdio configuration in `claude_desktop_config.json`. ## Private LAN GitLab Architecture diff --git a/packages/gitlab-mcp/docs/tools/code-review.md b/packages/gitlab-mcp/docs/tools/code-review.md index 7768b660..a1e4b1c2 100644 --- a/packages/gitlab-mcp/docs/tools/code-review.md +++ b/packages/gitlab-mcp/docs/tools/code-review.md @@ -32,6 +32,7 @@ Find and inspect merge requests. |--------|-------------| | `list` | List merge requests with filtering | | `get` | Get single MR by IID or branch name | +| `closing_issues` | Read one page of visible issues GitLab would close on merge; does not close issues | | `diffs` | Get file changes/diffs for an MR | | `compare` | Compare two branches or commits | | `versions` | List all diff versions of an MR (each push creates a version) | diff --git a/packages/gitlab-mcp/docs/tools/repository.md b/packages/gitlab-mcp/docs/tools/repository.md index fd9a4a02..ae47e824 100644 --- a/packages/gitlab-mcp/docs/tools/repository.md +++ b/packages/gitlab-mcp/docs/tools/repository.md @@ -230,8 +230,8 @@ always returns base64. | `update_branch_protection` | Update protection rules for a branch | | `create_tag` | Create a new tag in the repository | | `delete_tag` | Delete a tag from the repository | -| `protect_tag` | Add protection rules to a tag pattern (Premium) | -| `unprotect_tag` | Remove protection from a tag pattern (Premium) | +| `protect_tag` | Add protection rules to a tag pattern (all tiers) | +| `unprotect_tag` | Remove protection from a tag pattern (all tiers) | ::: code-group diff --git a/packages/gitlab-mcp/src/cli/install/types.ts b/packages/gitlab-mcp/src/cli/install/types.ts index 4b45c6c4..85533c1a 100644 --- a/packages/gitlab-mcp/src/cli/install/types.ts +++ b/packages/gitlab-mcp/src/cli/install/types.ts @@ -190,9 +190,12 @@ export const CLIENT_METADATA: Record = { 'roo-code': { name: 'Roo Code', configPaths: { - darwin: '~/.roo/mcp.json', - win32: '%USERPROFILE%/.roo/mcp.json', - linux: '~/.roo/mcp.json', + darwin: + '~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', + win32: + '%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', + linux: + '~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', }, supportsCliInstall: false, detectionMethod: 'config-file', diff --git a/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts new file mode 100644 index 00000000..2acf1d4a --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts @@ -0,0 +1,111 @@ +import { + extractGroupsFromArgs, + extractProjectsFromArgs, + numericTargets, +} from '../profiles/scope-enforcer'; +import type { ScopeConfig } from '../profiles/types'; +import type { ToolRequirements } from '../types'; +import { getNamespaceTier } from '../services/NamespaceTierDetector'; +import { TIER_ORDER } from '../services/InstanceCapabilities'; +import { + hasNamespaceSubscriptions, + runWithNamespaceTier, +} from '../services/namespace-tier-context'; +import { canonicalProjectPath } from '../utils/api-path'; +import { mapBounded } from '../utils/bounded-map'; +import type { GitLabTier } from '../services/GitLabVersionDetector'; + +export interface NamespaceTarget { + path: string; + kind: 'project' | 'group' | 'either'; +} + +/** Stop optional lookups once one target makes a complete tier decision impossible. */ +class UnknownSubscription extends Error {} + +export function namespaceTargetsForScope(scope: ScopeConfig | undefined): NamespaceTarget[] { + if (!scope) return []; + const targets: NamespaceTarget[] = []; + for (const path of [scope.project, ...(scope.projects ?? [])]) + if (path !== undefined) targets.push({ path, kind: 'project' }); + for (const path of [scope.group, scope.namespace, ...(scope.groups ?? [])]) + if (path !== undefined) targets.push({ path, kind: 'group' }); + return targets; +} + +/** Numeric namespace arguments retain the handler's group-first ambiguity. */ +export function namespaceTargetsForArgs(args: Record): NamespaceTarget[] { + const targets: NamespaceTarget[] = numericTargets(args).map(({ id, kind }) => ({ + path: id, + kind, + })); + for (const path of extractProjectsFromArgs(args)) + if (!/^\d+$/.test(path)) targets.push({ path, kind: 'project' }); + for (const path of extractGroupsFromArgs(args)) + if (!/^\d+$/.test(path)) targets.push({ path, kind: 'group' }); + return targets; +} + +export function needsNamespaceTier(requirements: ToolRequirements | undefined): boolean { + if (!requirements) return false; + return [ + requirements.default, + ...Object.values(requirements.actions ?? {}), + ...Object.values(requirements.parameters ?? {}), + ].some((req) => req.tier !== undefined && req.tier !== 'free'); +} + +function distinctRoots(targets: readonly NamespaceTarget[]): NamespaceTarget[] | null { + const unique = new Map(); + for (const target of targets) { + // An opaque GID is resolved by its owning handler, not guessed to be a namespace. + if (target.path.startsWith('gid://')) return null; + let path: string; + try { + path = canonicalProjectPath(target.path); + } catch { + return null; + } + const slash = path.indexOf('/'); + const key = /^\d+$/.test(path) + ? `${target.kind}:${path}` + : slash < 0 + ? path + : path.slice(0, slash); + unique.set(key, target); + } + return [...unique.values()]; +} + +function selectedTier(tiers: readonly GitLabTier[], union: boolean): GitLabTier | null { + let tier: GitLabTier | null = null; + for (const observation of tiers) { + if (!union && tier !== null && tier !== observation) return null; + if (tier === null || TIER_ORDER[observation] > TIER_ORDER[tier]) tier = observation; + } + return tier; +} + +/** Catalogs expose the union; a call spanning different subscriptions defers to GitLab. */ +export async function withNamespaceCapabilities( + instanceUrl: string, + targets: readonly NamespaceTarget[], + union: boolean, + fn: () => T, +): Promise> { + if (!hasNamespaceSubscriptions(instanceUrl)) return await fn(); + const unique = distinctRoots(targets); + if (unique === null) return await runWithNamespaceTier(instanceUrl, null, fn); + let observations; + try { + observations = await mapBounded(unique, 5, async (target) => { + const info = await getNamespaceTier(target.path, target.kind); + if (info.tier === null) throw new UnknownSubscription(); + return info.tier; + }); + } catch (error) { + if (!(error instanceof UnknownSubscription)) throw error; + return await runWithNamespaceTier(instanceUrl, null, fn); + } + return await runWithNamespaceTier(instanceUrl, selectedTier(observations, union), fn); +} diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 7e9ec979..ad72f3ec 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -39,7 +39,16 @@ import { BrowseProjectsSchema, BrowseTodosSchema, } from '../entities/core/schema-readonly'; -import { ManageProjectSchema, ManageTodosSchema } from '../entities/core/schema'; +import { + ManageProjectSchema, + ManageNamespaceSchema, + ManageTodosSchema, +} from '../entities/core/schema'; +import { BrowseIterationsSchema } from '../entities/iterations/schema-readonly'; +import { BrowseMilestonesSchema } from '../entities/milestones/schema-readonly'; +import { ManageMilestoneSchema } from '../entities/milestones/schema'; +import { BrowseRefsSchema } from '../entities/refs/schema-readonly'; +import { ManageRefSchema } from '../entities/refs/schema'; import { BrowseSearchSchema } from '../entities/search/schema-readonly'; import { BrowseMergeRequestsSchema } from '../entities/mrs/schema-readonly'; import { @@ -103,6 +112,12 @@ const PROJECT_LIST_OPTIONS = new Set([ ]); /** The input schemas of the tools whose calls the scope rewrites, filters or emulates. */ const SCOPED_TOOL_SCHEMAS: Readonly> = { + browse_iterations: BrowseIterationsSchema, + browse_milestones: BrowseMilestonesSchema, + manage_milestone: ManageMilestoneSchema, + browse_refs: BrowseRefsSchema, + manage_ref: ManageRefSchema, + manage_namespace: ManageNamespaceSchema, browse_work_items: BrowseWorkItemsSchema, manage_work_item: ManageWorkItemSchema, browse_registry: BrowseRegistrySchema, diff --git a/packages/gitlab-mcp/src/entities/context/whoami.ts b/packages/gitlab-mcp/src/entities/context/whoami.ts index 8bdbd859..8f446d1b 100644 --- a/packages/gitlab-mcp/src/entities/context/whoami.ts +++ b/packages/gitlab-mcp/src/entities/context/whoami.ts @@ -252,7 +252,7 @@ function buildServerInfo(): WhoamiServerInfo { const connectionManager = ConnectionManager.getInstance(); const instanceInfo = connectionManager.getInstanceInfo(getGitLabApiUrlFromContext()); version = instanceInfo.version; - tier = instanceInfo.tier; + if (instanceInfo.tier !== null) tier = instanceInfo.tier; } catch { // Connection not initialized - use defaults } diff --git a/packages/gitlab-mcp/src/entities/instance-version.ts b/packages/gitlab-mcp/src/entities/instance-version.ts index 69e38767..36f60b8b 100644 --- a/packages/gitlab-mcp/src/entities/instance-version.ts +++ b/packages/gitlab-mcp/src/entities/instance-version.ts @@ -15,7 +15,7 @@ export function instanceAtLeast(minVersion: string): boolean { } /** Detected version/tier of the instance serving the current request, if known. */ -export function currentInstance(): { version: string; tier: GitLabTier } | undefined { +export function currentInstance(): { version: string; tier: GitLabTier | null } | undefined { try { return ConnectionManager.getInstance().getInstanceInfo(getGitLabApiUrlFromContext()); } catch { diff --git a/packages/gitlab-mcp/src/entities/workitems/registry.ts b/packages/gitlab-mcp/src/entities/workitems/registry.ts index bfba9eb6..b00e8bea 100644 --- a/packages/gitlab-mcp/src/entities/workitems/registry.ts +++ b/packages/gitlab-mcp/src/entities/workitems/registry.ts @@ -590,7 +590,9 @@ export const workitemsToolRegistry: ToolRegistry = new Map }, >( - tools: T[], + tools: () => T[], caller: Caller, registryManager: import('./registry-manager').RegistryManager, ): Promise { @@ -672,10 +680,20 @@ async function withoutRestrictedTools< policy = (await getConfigurationService().resolve(caller)).policy; } catch (error: unknown) { logWarn('Could not read the caller settings for the tool list', { err: error as Error }); - return tools; + return tools(); } + const catalog = await runWithCaller(caller, async () => { + let targets = namespaceTargetsForScope(policy.scope); + if (hasNamespaceSubscriptions(caller.instanceUrl) && GITLAB_ALLOWED_PROJECT_IDS.length > 0) { + if (canResolveNamespaceTier(caller.instanceUrl)) { + policy = await withOperatorProjects(policy); + targets = namespaceTargetsForScope(policy.scope); + } else targets = []; + } + return withNamespaceCapabilities(caller.instanceUrl, targets, true, tools); + }); const listed: T[] = []; - for (const tool of tools) { + for (const tool of catalog) { const inputSchema = listedToolSchema( policy, registryManager.getToolFacts(tool.name), @@ -783,7 +801,7 @@ async function executeInScope( toolName: string, requested: Record, policy: EffectivePolicy, - instanceUrl: string | undefined, + instanceUrl: string, requestScopes: readonly string[] | undefined, expired: () => boolean, ): Promise { @@ -792,11 +810,23 @@ async function executeInScope( // executeScoped wraps this runner so every call it makes in place of the requested one is // held to the preset's denied actions; the operator's are checked by each tool itself. const run = (tool: string, toolArgs: Record): Promise => { - if (tool === 'browse_duo_sessions' || tool === 'manage_duo_session') - return registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes, policy); - if (requestScopes === undefined) - return registryManager.executeTool(tool, toolArgs, instanceUrl); - return registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes); + const dispatch = (validated: Record): Promise => { + if (expired()) return Promise.reject(new Error('Tool call expired before execution')); + if (tool === 'browse_duo_sessions' || tool === 'manage_duo_session') + return registryManager.executeTool(tool, validated, instanceUrl, requestScopes, policy); + if (requestScopes === undefined) + return registryManager.executeTool(tool, validated, instanceUrl); + return registryManager.executeTool(tool, validated, instanceUrl, requestScopes); + }; + const definition = registryManager.getTool(tool, instanceUrl, requestScopes); + const action = toolArgs.action; + if (typeof action === 'string' && definition?.unavailableActions?.has(action.toLowerCase())) + return dispatch(toolArgs); + if (!needsNamespaceTier(definition?.requirements)) return dispatch(toolArgs); + const validated = parseScopedArguments(tool, toolArgs); + return withNamespaceCapabilities(instanceUrl, namespaceTargetsForArgs(validated), false, () => + dispatch(validated), + ); }; return executeScoped(run, toolName, requested, policy, true); } @@ -1022,7 +1052,7 @@ export async function setupHandlers(server: Server): Promise { // OAuth deployments declare the token every tool needs; static-token mode keeps // the descriptors as before. const securitySchemes = isCatalogOAuth() ? OAUTH_SECURITY_SCHEMES : undefined; - const catalog = + const catalog = () => scopes === undefined ? registryManager.getAllToolDefinitions(sessionInstanceUrl, mode) : registryManager.getAllToolDefinitions(sessionInstanceUrl, mode, scopes); diff --git a/packages/gitlab-mcp/src/oauth/types.ts b/packages/gitlab-mcp/src/oauth/types.ts index f797e1f2..2a7d5c26 100644 --- a/packages/gitlab-mcp/src/oauth/types.ts +++ b/packages/gitlab-mcp/src/oauth/types.ts @@ -11,10 +11,8 @@ * Free group and Ultimate group simultaneously. */ export interface NamespaceTierInfo { - /** Tier level (free, premium, ultimate) */ - tier: 'free' | 'premium' | 'ultimate'; - /** Available features for this tier */ - features: Record; + /** Confirmed subscription tier, or null when billing information is undisclosed. */ + tier: 'free' | 'premium' | 'ultimate' | null; /** Cache timestamp */ cachedAt: Date; } diff --git a/packages/gitlab-mcp/src/registry-manager.ts b/packages/gitlab-mcp/src/registry-manager.ts index faf29880..6f05db45 100644 --- a/packages/gitlab-mcp/src/registry-manager.ts +++ b/packages/gitlab-mcp/src/registry-manager.ts @@ -99,7 +99,13 @@ import { isToolAvailable, getRestrictedParameters, getUnavailableActions, + type CapabilityGate, } from './services/InstanceCapabilities'; +import { + currentNamespaceTier, + hasNamespaceSubscriptions, + runWithNamespaceTier, +} from './services/namespace-tier-context'; import { ConnectionManager } from './services/ConnectionManager'; import { duoToolRegistry, getDuoReadOnlyToolNames } from './entities/duo/registry'; import type { EffectivePolicy, ToolFacts } from './configuration/policy'; @@ -465,10 +471,10 @@ class RegistryManager { * Returns undefined fields when connection is not initialized. * @throws on unexpected errors (anything other than expected init errors). */ private loadInstanceContext(instanceUrl?: string): { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; } { - let instanceInfo: { tier: GitLabTier; version: string; adminModeActive?: boolean } | undefined; + let instanceInfo: CapabilityGate | undefined; try { const info = ConnectionManager.getInstance().getInstanceInfo(instanceUrl); instanceInfo = { tier: info.tier, version: info.version }; @@ -513,7 +519,7 @@ class RegistryManager { toolName: string, tool: EnhancedToolDefinition, ctx: { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; }, unavailableActions: ReadonlyMap, @@ -550,7 +556,7 @@ class RegistryManager { private unavailableActionsFor( toolName: string, tool: EnhancedToolDefinition, - ctx: { instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean } }, + ctx: { instanceInfo?: CapabilityGate }, ): ReadonlyMap { if (!ctx.instanceInfo || this.registries.get('context')?.has(toolName)) return NONE_UNAVAILABLE; return getUnavailableActions(tool.requirements, ctx.instanceInfo); @@ -558,7 +564,7 @@ class RegistryManager { /** Filter registries and build transformed tool map (schema + description overrides). */ private buildFilteredTools(ctx: { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; }): Map { const result = new Map(); @@ -659,22 +665,30 @@ class RegistryManager { */ private resolveCache(instanceUrl?: string): Map { const url = this.resolveCacheUrl(instanceUrl); - let cache = this.toolLookupCaches.get(url); + const key = this.capabilityCacheKey(url); + let cache = this.toolLookupCaches.get(key); if (!cache) { this.buildToolLookupCache(url); - cache = this.toolLookupCaches.get(url); + cache = this.toolLookupCaches.get(key); } return cache ?? new Map(); } + /** At most three known subscription views per instance, plus the unknown view. */ + private capabilityCacheKey(url: string): string { + const namespace = currentNamespaceTier(url); + return hasNamespaceSubscriptions(url) && namespace?.tier ? `${url}\0${namespace.tier}` : url; + } + private buildToolLookupCache(instanceUrl?: string): void { const url = this.resolveCacheUrl(instanceUrl); + const key = this.capabilityCacheKey(url); let ctx: ReturnType; try { ctx = this.loadInstanceContext(url); } catch (err) { - if (this.verifiedContextUrls.has(url)) { + if (this.verifiedContextUrls.has(key)) { // Fail-close during refresh: preserve the previous verified cache // instead of rebuilding with empty context (which would surface tools // the instance cannot use). Pre-init caches (built before @@ -691,10 +705,10 @@ class RegistryManager { // Do not leave a permissive pre-init snapshot live after an unexpected // refresh failure. Callers (e.g. handlers.ts) may catch refreshCache() // and continue serving whatever is still in the per-URL caches. - this.toolLookupCaches.delete(url); - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - this.filterStatsCaches.delete(url); + this.toolLookupCaches.delete(key); + this.toolDefinitionsCaches.delete(key); + this.toolNamesCaches.delete(key); + this.filterStatsCaches.delete(key); // On cold start there is no prior cache to preserve. Fail fast so the // process does not silently continue with no tools available. @@ -715,22 +729,22 @@ class RegistryManager { // temporary empty Map returned by resolveCache(); drop them before swapping. // filterStatsCaches is cleared here (not in invalidateCaches) so the last // good snapshot survives if this rebuild was triggered by a failed refresh. - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - this.filterStatsCaches.delete(url); + this.toolDefinitionsCaches.delete(key); + this.toolNamesCaches.delete(key); + this.filterStatsCaches.delete(key); // Atomic per-URL swap — in-flight requests that captured a reference // to the old map via getTool() keep working; new requests see the // updated cache for this URL - this.toolLookupCaches.set(url, newCache); + this.toolLookupCaches.set(key, newCache); // Track whether this cache was built with real context. Pre-init caches // (built before ConnectionManager is ready) must not be preserved on // failure. Updated AFTER swap so the flag describes the live cache. if (ctx.instanceInfo) { - this.verifiedContextUrls.add(url); + this.verifiedContextUrls.add(key); } else { - this.verifiedContextUrls.delete(url); + this.verifiedContextUrls.delete(key); } logDebug('Registry manager built cache after filtering', { @@ -744,14 +758,16 @@ class RegistryManager { */ private invalidateCaches(instanceUrl?: string): void { const url = this.resolveCacheUrl(instanceUrl); - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - // filterStatsCaches is intentionally NOT cleared here — it is preserved - // until buildToolLookupCache succeeds, so getFilterStats can fall back to - // the last good snapshot if the rebuild fails mid-way. - // readOnlyToolsCache is derived from registries + config flags (USE_*), - // not from instance URL — no need to clear on per-URL refresh. + const currentKey = this.capabilityCacheKey(url); + const prefix = `${url}\0`; + // Each successful build replaces its view atomically. A failed refresh keeps + // its own verified snapshot, including descriptors and statistics. this.buildToolLookupCache(url); + for (const key of this.toolLookupCaches.keys()) { + if (key === currentKey || (key !== url && !key.startsWith(prefix))) continue; + const tier = key === url ? null : (key.slice(prefix.length) as GitLabTier); + runWithNamespaceTier(url, tier, () => this.buildToolLookupCache(url)); + } } /** @@ -825,7 +841,8 @@ class RegistryManager { const unreachableMode = this.isUnreachableFor(instanceUrl); // In unreachable mode, rebuild every call (transient state — don't cache // a context-only list that would persist after recovery) - const cachedDefs = this.toolDefinitionsCaches.get(url)?.get(mode); + const key = this.capabilityCacheKey(url); + const cachedDefs = this.toolDefinitionsCaches.get(key)?.get(mode); if (cachedDefs === undefined || unreachableMode) { const contextTools = unreachableMode ? this.registries.get('context') : null; @@ -840,9 +857,9 @@ class RegistryManager { return this.forScopes(defs, scopes); } const modes = - this.toolDefinitionsCaches.get(url) ?? new Map(); + this.toolDefinitionsCaches.get(key) ?? new Map(); modes.set(mode, defs); - this.toolDefinitionsCaches.set(url, modes); + this.toolDefinitionsCaches.set(key, modes); return this.forScopes(defs, scopes); } @@ -884,7 +901,8 @@ class RegistryManager { ): ToolDefinition[] { const url = this.resolveCacheUrl(instanceUrl); const cache = this.resolveCache(instanceUrl); - const cachedDefs = this.toolDefinitionsCaches.get(url)?.get(mode); + const key = this.capabilityCacheKey(url); + const cachedDefs = this.toolDefinitionsCaches.get(key)?.get(mode); // Return the cached full-list when available (same object already stored by // getAllToolDefinitions on healthy path). If the cache is absent (e.g. first // call, or was cleared after an invalidation) build it now without the @@ -897,9 +915,9 @@ class RegistryManager { defs.push(this.toToolDefinition(tool, mode)); } const modes = - this.toolDefinitionsCaches.get(url) ?? new Map(); + this.toolDefinitionsCaches.get(key) ?? new Map(); modes.set(mode, defs); - this.toolDefinitionsCaches.set(url, modes); + this.toolDefinitionsCaches.set(key, modes); return defs; } @@ -1151,7 +1169,8 @@ class RegistryManager { const url = this.resolveCacheUrl(instanceUrl); const cache = this.resolveCache(instanceUrl); const unreachableMode = this.isUnreachableFor(instanceUrl); - const cachedNames = this.toolNamesCaches.get(url); + const key = this.capabilityCacheKey(url); + const cachedNames = this.toolNamesCaches.get(key); if (cachedNames === undefined || unreachableMode) { const contextTools = unreachableMode ? this.registries.get('context') : null; const names = Array.from(cache.keys()).filter( @@ -1160,7 +1179,7 @@ class RegistryManager { if (unreachableMode) { return names; } - this.toolNamesCaches.set(url, names); + this.toolNamesCaches.set(key, names); return names; } return cachedNames; @@ -1260,6 +1279,7 @@ class RegistryManager { // raw instanceUrl for reachability (preserves undefined → global fallback), // resolved url for instance context loading (avoids currentInstanceUrl leakage). const url = this.resolveCacheUrl(instanceUrl); + const key = this.capabilityCacheKey(url); const unreachableMode = this.isUnreachableFor(instanceUrl); const contextTools = unreachableMode ? this.registries.get('context') : null; @@ -1306,7 +1326,7 @@ class RegistryManager { // reflecting the real total so whoami doesn't show totalToolCount: 0. const filteredTotal = totalTools; return ( - (accountScopes === undefined ? this.filterStatsCaches.get(url) : undefined) ?? { + (accountScopes === undefined ? this.filterStatsCaches.get(key) : undefined) ?? { available: 0, total: filteredTotal, filteredByScopes: filteredTotal, @@ -1341,7 +1361,7 @@ class RegistryManager { filteredByActionDenial, filteredByAdmin, }; - if (accountScopes === undefined) this.filterStatsCaches.set(url, stats); + if (accountScopes === undefined) this.filterStatsCaches.set(key, stats); return stats; } diff --git a/packages/gitlab-mcp/src/services/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 6d13e099..2148bf19 100644 --- a/packages/gitlab-mcp/src/services/ConnectionManager.ts +++ b/packages/gitlab-mcp/src/services/ConnectionManager.ts @@ -1,5 +1,11 @@ import { GraphQLClient } from '../graphql/client'; -import { GitLabVersionDetector, GitLabInstanceInfo } from './GitLabVersionDetector'; +import { + GitLabVersionDetector, + GitLabInstanceInfo, + type GitLabTier, + type GitLabFeatures, +} from './GitLabVersionDetector'; +import { currentNamespaceTier, hasNamespaceSubscriptions } from './namespace-tier-context'; import { SchemaIntrospector, SchemaInfo, type SchemaFieldIndex } from './SchemaIntrospector'; import { detectTokenScopes, @@ -32,6 +38,12 @@ interface CacheEntry { timestamp: number; } +/** Instance facts projected into the current namespace, without mutating shared detection. */ +export type RequestInstanceInfo = Omit & { + tier: GitLabTier | null; + features: Partial; +}; + /** * Per-URL instance state. Each GitLab URL gets isolated client, detectors, * and introspection data — no shared mutable fields to overwrite. @@ -711,13 +723,25 @@ export class ConnectionManager { return state.schemaIntrospector; } - public getInstanceInfo(instanceUrl?: string): GitLabInstanceInfo { + public getInstanceInfo(instanceUrl?: string): RequestInstanceInfo { const [state, resolvedUrl] = this.resolveState(instanceUrl); if (!state.instanceInfo) { throw new Error( `Instance information is not available for ${resolvedUrl}. Initialization may have completed without version detection (OAuth deferred/REST-only mode).`, ); } + if (hasNamespaceSubscriptions(resolvedUrl)) { + const namespace = currentNamespaceTier(resolvedUrl); + const tier = namespace === undefined ? null : namespace.tier; + return { + ...state.instanceInfo, + tier, + features: + tier === null || state.instanceInfo.version === 'unknown' + ? {} + : GitLabVersionDetector.determineFeatures(state.instanceInfo.version, tier), + }; + } return state.instanceInfo; } @@ -780,13 +804,14 @@ export class ConnectionManager { public isFeatureAvailable( feature: keyof GitLabInstanceInfo['features'], instanceUrl?: string, - ): boolean { + ): boolean | null { const url = instanceUrl ? normalizeInstanceUrl(instanceUrl) : this.callInstanceUrl(); if (!url) return false; const state = this.instances.get(url); if (!state?.instanceInfo) return false; this.touchInstance(url); // Keep entry alive in LRU window. - return state.instanceInfo.features[feature]; + const value = this.getInstanceInfo(url).features[feature]; + return value ?? null; } public getTier(instanceUrl?: string): string { @@ -795,7 +820,8 @@ export class ConnectionManager { const state = this.instances.get(url); if (!state?.instanceInfo) return 'unknown'; this.touchInstance(url); // Keep entry alive in LRU window. - return state.instanceInfo.tier; + const tier = this.getInstanceInfo(url).tier; + return tier ?? 'unknown'; } public getVersion(instanceUrl?: string): string { diff --git a/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts b/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts index c9580aff..4e0bf391 100644 --- a/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts +++ b/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts @@ -129,7 +129,7 @@ export class GitLabVersionDetector { const version = await this.detectVersion(); const tier = await this.detectTier(); - const features = this.determineFeatures(version, tier); + const features = GitLabVersionDetector.determineFeatures(version, tier); this.cachedInfo = { version, @@ -239,7 +239,7 @@ export class GitLabVersionDetector { return 'free'; } - private determineFeatures(version: string, tier: GitLabTier): GitLabFeatures { + public static determineFeatures(version: string, tier: GitLabTier): GitLabFeatures { const v = parseVersion(version); const features: GitLabFeatures = { diff --git a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts index 2aca2fc5..90f5b649 100644 --- a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts +++ b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts @@ -15,10 +15,10 @@ import { parseVersion } from '../utils/version'; export interface InstanceCapabilities { /** Detected GitLab version (semver string), or 'unknown' when not yet probed. */ version: string; - /** Licensed tier. Free covers CE and EE-without-license. */ - tier: GitLabTier; - /** Per-feature availability map (epics, iterations, vulnerabilities, ...). */ - features: GitLabFeatures; + /** Observed subscription or license tier; null means unavailable billing metadata. */ + tier: GitLabTier | null; + /** Per-feature availability; an absent key is unknown, not a confirmed refusal. */ + features: Partial; /** Token scopes (api, read_api, ...). Empty when scope detection was skipped. */ scopes: GitLabScope[]; /** @@ -44,7 +44,11 @@ export interface InstanceCapabilities { export type CapabilityGate = Pick; /** Tier hierarchy for comparison: free < premium < ultimate. */ -const TIER_ORDER: Record = { free: 0, premium: 1, ultimate: 2 }; +export const TIER_ORDER: Readonly> = { + free: 0, + premium: 1, + ultimate: 2, +}; /** Default requirement applied when a tool/action omits an explicit tier. */ const DEFAULT_TIER = 'free' as const; @@ -94,7 +98,7 @@ export function meetsRequirement(req: ToolRequirement, caps: CapabilityGate): bo if (req.requiresAdmin && caps.adminModeActive === false) return false; if (caps.version === 'unknown') return true; if (parseVersion(caps.version) < parseVersion(effectiveMinVersion(req))) return false; - if (!isTierSufficient(caps.tier, req.tier)) return false; + if (caps.tier !== null && !isTierSufficient(caps.tier, req.tier)) return false; return true; } @@ -179,7 +183,7 @@ export function getUnmetReason( return `Requires GitLab ${minVersion}+, current version is ${caps.version}`; } if (!req) return null; - if (!isTierSufficient(caps.tier, req.tier)) { + if (caps.tier !== null && !isTierSufficient(caps.tier, req.tier)) { return `Requires GitLab ${req.tier ?? DEFAULT_TIER} tier or higher, current tier is ${caps.tier}`; } return null; diff --git a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts index ea66ca2a..947b1eed 100644 --- a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts +++ b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts @@ -1,360 +1,209 @@ /** - * Namespace Tier Detector - * - * Detects GitLab tier (free/premium/ultimate) per namespace. - * CRITICAL: Tier is per-NAMESPACE, not per-instance! - * On gitlab.com, one user can access Free group and Ultimate group simultaneously. - * - * Features: - * - Per-session namespace tier caching (5 min TTL) - * - GraphQL query for namespace tier detection - * - Feature availability mapping per tier - */ - -import { logDebug, logWarn } from '../logger.js'; -import { NamespaceTierInfo } from '../oauth/types.js'; -import { getTokenContext } from '../oauth/token-context.js'; -import { enhancedFetch } from '../utils/fetch.js'; -import { GITLAB_BASE_URL } from '../config'; - -/** - * Cache TTL for namespace tier (5 minutes) - */ -const NAMESPACE_TIER_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * In-memory namespace tier cache: session id -> account key (instance, user and namespace - * path) -> tier. A session can work with several accounts (linked accounts keep the - * session id), and each sees its own tier of a path. + * Subscription observations scoped to a credential and root namespace. + * These disposable facts guide discovery, not GitLab authorization. */ +import { createHash } from 'node:crypto'; +import type { NamespaceTierInfo } from '../oauth/types'; +import type { GitLabTier } from './GitLabVersionDetector'; +import { getTokenContext } from '../oauth/token-context'; +import { enhancedFetch, getAuthHeaders } from '../utils/fetch'; +import { getGitLabBaseUrl } from '../utils/gitlab-base-url'; +import { normalizeInstanceUrl } from '../utils/url'; +import { readBoundedJson } from '../utils/bounded-json'; +import { canonicalProjectPath, encodeApiSegment } from '../utils/api-path'; +import { logWarn } from '../logger'; +import { isOAuthEnabled } from '../oauth/config'; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_CAPACITY = 1024; const namespaceTierCache = new Map>(); -/** - * GraphQL query to get namespace tier - */ -const NAMESPACE_TIER_QUERY = ` - query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - ... on Group { - plan - } - - ... on Project { - group { - plan - } - } - } - } -`; - -/** - * Feature availability per tier - */ -const TIER_FEATURES: Record> = { - free: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: false, - iterations: false, - roadmaps: false, - okrs: false, - healthStatus: false, - weight: false, - multiLevelEpics: false, - portfolioManagement: false, - requirements: false, - securityDashboard: false, - complianceFramework: false, - }, - premium: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: true, - iterations: true, - roadmaps: true, - okrs: false, - healthStatus: true, - weight: true, - multiLevelEpics: true, - portfolioManagement: true, - requirements: true, - securityDashboard: false, - complianceFramework: true, - }, - ultimate: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: true, - iterations: true, - roadmaps: true, - okrs: true, - healthStatus: true, - weight: true, - multiLevelEpics: true, - portfolioManagement: true, - requirements: true, - securityDashboard: true, - complianceFramework: true, - }, -}; - -/** - * Normalize plan name to tier - */ -function normalizeTier(plan: string | null | undefined): 'free' | 'premium' | 'ultimate' { - if (!plan) return 'free'; - - const normalized = plan.toLowerCase(); - - if (normalized.includes('ultimate') || normalized.includes('gold')) { - return 'ultimate'; - } - if (normalized.includes('premium') || normalized.includes('silver')) { - return 'premium'; - } - if (normalized.includes('bronze') || normalized.includes('starter')) { - return 'premium'; // Bronze/Starter maps to Premium tier features - } +interface Identity { + baseUrl: string; + headers: Record; + credential: string; + sessionId: string; +} - return 'free'; +// Plan names: https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/app/models/ee/plan.rb +// Legacy plans without an equivalent in the supported three-tier model stay unknown. +const PLAN_TIERS = new Map([ + ['free', 'free'], + ['default', 'free'], + ['premium', 'premium'], + ['premium_trial', 'premium'], + ['silver', 'premium'], + ['ultimate', 'ultimate'], + ['ultimate_trial', 'ultimate'], + ['gold', 'ultimate'], + ['opensource', 'ultimate'], +]); + +function identity(): Identity | null { + const context = getTokenContext(); + if (isOAuthEnabled() && !context?.gitlabToken) return null; + if ( + context?.gitlabScopes && + !context.gitlabScopes.some((scope) => scope === 'api' || scope === 'read_api') + ) + return null; + const baseUrl = getGitLabBaseUrl(); + if (!baseUrl) return null; + const headers = getAuthHeaders(baseUrl); + if (Object.keys(headers).length === 0) return null; + const credential = createHash('sha256') + .update(context ? String(context.gitlabUserId) : '') + .update('\0') + .update(JSON.stringify(headers)) + .digest('hex'); + return { + baseUrl: normalizeInstanceUrl(baseUrl), + headers, + credential, + sessionId: context ? context.sessionId : credential, + }; } -/** - * Get features for a tier - */ -export function getFeaturesForTier(tier: 'free' | 'premium' | 'ultimate'): Record { - return { ...TIER_FEATURES[tier] }; +/** Optional billing lookups use exactly the transport's selected instance and credential. */ +export function canResolveNamespaceTier(instanceUrl: string): boolean { + const caller = identity(); + return caller !== null && caller.baseUrl === normalizeInstanceUrl(instanceUrl); } -/** Key of a namespace path as one account sees it; a newline occurs in none of the parts. */ -function accountKey(baseUrl: string, gitlabUserId: number, namespacePath: string): string { - return `${baseUrl}\n${gitlabUserId}\n${namespacePath}`; +function unknown(): NamespaceTierInfo { + return { tier: null, cachedAt: new Date() }; } -/** - * Get cached namespace tier info - */ -function getCachedTier( - sessionId: string, - key: string, - namespacePath: string, -): NamespaceTierInfo | null { +function cached(sessionId: string, key: string): NamespaceTierInfo | undefined { const entries = namespaceTierCache.get(sessionId); - const cached = entries?.get(key); - - if (!entries || !cached) return null; - - // Check TTL - const age = Date.now() - cached.cachedAt.getTime(); - if (age > NAMESPACE_TIER_CACHE_TTL_MS) { - entries.delete(key); - if (entries.size === 0) namespaceTierCache.delete(sessionId); - logDebug('Namespace tier cache expired', { - sessionId, - namespacePath, - ageMs: age, - }); - return null; + const value = entries?.get(key); + if (!value) return undefined; + if (Date.now() - value.cachedAt.getTime() > CACHE_TTL_MS) { + entries?.delete(key); + if (entries?.size === 0) namespaceTierCache.delete(sessionId); + return undefined; } - - return cached; + return value; } -/** - * Set cached namespace tier info - */ -function setCachedTier( - sessionId: string, - key: string, - namespacePath: string, - tierInfo: NamespaceTierInfo, -): void { +function remember(sessionId: string, key: string, value: NamespaceTierInfo): void { let entries = namespaceTierCache.get(sessionId); if (!entries) { entries = new Map(); namespaceTierCache.set(sessionId, entries); } - entries.set(key, tierInfo); - - logDebug('Namespace tier cached', { - sessionId, - namespacePath, - tier: tierInfo.tier, - }); + entries.set(key, value); + let count = 0; + for (const values of namespaceTierCache.values()) count += values.size; + if (count <= CACHE_CAPACITY) return; + const oldestSession = namespaceTierCache.keys().next().value; + if (oldestSession === undefined) return; + const oldest = namespaceTierCache.get(oldestSession); + if (!oldest) return; + const oldestKey = oldest.keys().next().value; + if (oldestKey !== undefined) oldest.delete(oldestKey); + if (oldest.size === 0) namespaceTierCache.delete(oldestSession); } -/** - * Clear namespace tier cache for a session - * Called when user switches instances or session expires - */ export function clearNamespaceTierCache(sessionId?: string): void { - if (sessionId) { - // Clear only for specific session - namespaceTierCache.delete(sessionId); - logDebug('Namespace tier cache cleared for session', { sessionId }); - } else { - // Clear entire cache - namespaceTierCache.clear(); - logDebug('All namespace tier caches cleared'); - } + if (sessionId !== undefined) namespaceTierCache.delete(sessionId); + else namespaceTierCache.clear(); } -/** - * Query GitLab for namespace tier via GraphQL - */ -async function queryNamespaceTier( - namespacePath: string, - token: string, - baseUrl: string, -): Promise { - const graphqlUrl = `${baseUrl}/api/graphql`; - - try { - const response = await enhancedFetch(graphqlUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: NAMESPACE_TIER_QUERY, - variables: { fullPath: namespacePath }, - }), - // Pass baseUrl for rate limiting to match InstanceRegistry key (supports subpath deployments) - rateLimitBaseUrl: baseUrl, - }); - - if (!response.ok) { - logWarn('Failed to query namespace tier', { - namespacePath, - status: response.status, - }); - // Return free tier as fallback - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } - - const result = (await response.json()) as { - data?: { - namespace?: { - plan?: string; - group?: { plan?: string }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (result.errors?.length) { - logWarn('GraphQL errors querying namespace tier', { - namespacePath, - errors: result.errors.map((e) => e.message), - }); - } - - // Extract plan from response - const namespace = result.data?.namespace; - const plan = namespace?.plan ?? namespace?.group?.plan ?? null; - const tier = normalizeTier(plan); - - return { - tier, - features: getFeaturesForTier(tier), - cachedAt: new Date(), - }; - } catch (error) { - logWarn('Error querying namespace tier', { - namespacePath, - err: error instanceof Error ? error : new Error(String(error)), - }); - - // Return free tier as fallback - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; +async function readMetadata( + path: string, + caller: Identity, + limit: number, +): Promise<{ status: number; value: unknown }> { + const response = await enhancedFetch(`${caller.baseUrl}/api/v4/${path}`, { + method: 'GET', + headers: caller.headers, + rateLimitBaseUrl: caller.baseUrl, + }); + if (!response.ok) { + await response.body?.cancel(); + logWarn('Namespace subscription metadata is unavailable', { status: response.status }); + return { status: response.status, value: null }; } + return { status: response.status, value: await readBoundedJson(response, limit) }; } -/** - * Get namespace tier with caching - * Uses current token context for session ID and token - * - * @param namespacePath - Full path of the namespace (e.g., "gitlab-org" or "gitlab-org/gitlab") - * @returns Namespace tier info with features - */ -export async function getNamespaceTier(namespacePath: string): Promise { - const context = getTokenContext(); - - if (!context) { - logWarn('No token context available for namespace tier detection'); - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } - - const { sessionId, gitlabToken, gitlabUserId, apiUrl } = context; - const baseUrl = apiUrl || GITLAB_BASE_URL; - - if (!baseUrl) { - logWarn('No base URL available for namespace tier detection'); - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } - - // Check cache first - const key = accountKey(baseUrl, gitlabUserId, namespacePath); - const cached = getCachedTier(sessionId, key, namespacePath); - if (cached) { - logDebug('Namespace tier from cache', { - namespacePath, - tier: cached.tier, - }); - return cached; - } - - // Query GitLab - const tierInfo = await queryNamespaceTier(namespacePath, gitlabToken, baseUrl); +function stringField(value: unknown, field: string): string | null { + if (value === null || typeof value !== 'object' || !(field in value)) return null; + const result = (value as Record)[field]; + return typeof result === 'string' ? result : null; +} - // Cache result - setCachedTier(sessionId, key, namespacePath, tierInfo); +/** GitLab namespaces inherit the subscription of their root namespace. */ +function rootOf(path: string): string | null { + const canonical = canonicalProjectPath(path); + const separator = canonical.indexOf('/'); + const root = separator === -1 ? canonical : canonical.slice(0, separator); + // Namespace::URL_MAX_LENGTH, app/models/namespace.rb at v16.0.0-ee. + return root.length > 0 && root.length <= 255 ? root : null; +} - return tierInfo; +/** Match the handlers' group-first resolution, falling through only on a confirmed 404. */ +async function numericTargetPath( + id: string, + kind: 'project' | 'group' | 'either', + caller: Identity, +): Promise { + const collection = kind === 'project' ? 'projects' : 'groups'; + const query = collection === 'groups' ? '?with_projects=false' : ''; + const { status, value } = await readMetadata( + `${collection}/${encodeApiSegment(id)}${query}`, + caller, + 1024 * 1024, + ); + if (kind === 'either' && status === 404) return numericTargetPath(id, 'project', caller); + return stringField(value, kind === 'project' ? 'path_with_namespace' : 'full_path'); } /** - * Check if a feature is available for a namespace - * - * @param namespacePath - Full path of the namespace - * @param feature - Feature name to check - * @returns true if feature is available + * Read a root subscription, resolving typed numeric targets first. + * GitLab 16.0 exposes plan only with admin_namespace permission: + * https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/lib/ee/api/entities/namespace.rb#L28-30 */ -export async function isFeatureAvailable(namespacePath: string, feature: string): Promise { - const tierInfo = await getNamespaceTier(namespacePath); - return tierInfo.features[feature] ?? false; +export async function getNamespaceTier( + namespacePath: string, + targetKind?: 'project' | 'group' | 'either', +): Promise { + const canonical = canonicalProjectPath(namespacePath); + const root = rootOf(canonical); + if (root === null) return unknown(); + const caller = identity(); + if (caller === null) return unknown(); + const numeric = targetKind !== undefined && /^\d+$/.test(canonical); + const target = numeric ? `${targetKind}:${canonical}` : `namespace:${root}`; + const key = JSON.stringify([caller.baseUrl, caller.credential, target]); + const previous = cached(caller.sessionId, key); + if (previous) return previous; + + let observation: NamespaceTierInfo; + try { + const path = numeric ? await numericTargetPath(canonical, targetKind, caller) : root; + const namespace = path === null ? null : rootOf(path); + if (namespace === null) observation = unknown(); + else { + const { value } = await readMetadata( + `namespaces/${encodeApiSegment(namespace)}`, + caller, + 64 * 1024, + ); + const plan = stringField(value, 'plan'); + const tier = plan === null ? undefined : PLAN_TIERS.get(plan.toLowerCase()); + observation = { tier: tier ?? null, cachedAt: new Date() }; + } + } catch { + // Provider bodies and parser exceptions can contain secrets; never include them. + logWarn('Namespace subscription metadata could not be read'); + observation = unknown(); + } + remember(caller.sessionId, key, observation); + return observation; } -/** - * Get namespace tier metrics (for monitoring) - */ export function getNamespaceTierCacheMetrics(): { totalEntries: number; entriesBySession: Map; @@ -365,6 +214,5 @@ export function getNamespaceTierCacheMetrics(): { entriesBySession.set(sessionId, entries.size); totalEntries += entries.size; } - return { totalEntries, entriesBySession }; } diff --git a/packages/gitlab-mcp/src/services/WidgetAvailability.ts b/packages/gitlab-mcp/src/services/WidgetAvailability.ts index 52d88339..de87f9cd 100644 --- a/packages/gitlab-mcp/src/services/WidgetAvailability.ts +++ b/packages/gitlab-mcp/src/services/WidgetAvailability.ts @@ -2,7 +2,7 @@ import { WorkItemWidgetType, WorkItemWidgetTypes } from '../graphql/workItems'; import { ConnectionManager } from './ConnectionManager'; import { GitLabTier } from './GitLabVersionDetector'; import { parseVersion } from '../utils/version'; -import { effectiveMinVersion } from './InstanceCapabilities'; +import { effectiveMinVersion, TIER_ORDER } from './InstanceCapabilities'; import { logDebug } from '../logger'; interface WidgetRequirement { @@ -20,15 +20,9 @@ export interface WidgetValidationFailure { requiredVersion: string; detectedVersion: string; requiredTier: GitLabTier; - currentTier: GitLabTier; + currentTier: GitLabTier | null; } -const TIER_HIERARCHY: Record = { - free: 0, - premium: 1, - ultimate: 2, -}; - /** * Maps manage_work_item input parameters to their corresponding widget types. * Includes both current schema parameters and those planned for #135. @@ -128,12 +122,12 @@ export class WidgetAvailability { } // Check tier requirement - if (requirement.tier === 'free') { - return true; // Available to all tiers + if (requirement.tier === 'free' || instanceInfo.tier === null) { + return true; // No confirmed subscription restriction. } - const requiredTierLevel = TIER_HIERARCHY[requirement.tier]; - const actualTierLevel = TIER_HIERARCHY[instanceInfo.tier]; + const requiredTierLevel = TIER_ORDER[requirement.tier]; + const actualTierLevel = TIER_ORDER[instanceInfo.tier]; return actualTierLevel >= requiredTierLevel; } catch { @@ -171,7 +165,7 @@ export class WidgetAvailability { const connectionManager = ConnectionManager.getInstance(); let instanceVersion: string; - let instanceTier: GitLabTier; + let instanceTier: GitLabTier | null; try { const instanceInfo = connectionManager.getInstanceInfo(instanceUrl); @@ -212,9 +206,9 @@ export class WidgetAvailability { } // Check tier requirement - if (requirement.tier !== 'free') { - const requiredTierLevel = TIER_HIERARCHY[requirement.tier]; - const actualTierLevel = TIER_HIERARCHY[instanceTier]; + if (requirement.tier !== 'free' && instanceTier !== null) { + const requiredTierLevel = TIER_ORDER[requirement.tier]; + const actualTierLevel = TIER_ORDER[instanceTier]; if (actualTierLevel < requiredTierLevel) { return { diff --git a/packages/gitlab-mcp/src/services/namespace-tier-context.ts b/packages/gitlab-mcp/src/services/namespace-tier-context.ts new file mode 100644 index 00000000..ddd40bcb --- /dev/null +++ b/packages/gitlab-mcp/src/services/namespace-tier-context.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { GitLabTier } from './GitLabVersionDetector'; +import { normalizeInstanceUrl } from '../utils/url'; + +interface NamespaceTierContext { + instanceUrl: string; + tier: GitLabTier | null; +} + +const storage = new AsyncLocalStorage(); + +/** GitLab.com subscriptions attach to top-level groups, not the shared instance. + * https://docs.gitlab.com/subscriptions/manage_subscription/ */ +export function hasNamespaceSubscriptions(instanceUrl: string): boolean { + return normalizeInstanceUrl(instanceUrl) === 'https://gitlab.com'; +} + +export function currentNamespaceTier(instanceUrl: string): NamespaceTierContext | undefined { + const context = storage.getStore(); + return context?.instanceUrl === normalizeInstanceUrl(instanceUrl) ? context : undefined; +} + +/** A request's subscription never changes another request or the connection's metadata. */ +export function runWithNamespaceTier( + instanceUrl: string, + tier: GitLabTier | null, + fn: () => T, +): T { + return storage.run({ instanceUrl: normalizeInstanceUrl(instanceUrl), tier }, fn); +} diff --git a/packages/gitlab-mcp/src/utils/error-handler.ts b/packages/gitlab-mcp/src/utils/error-handler.ts index f2a462b3..fbc49e69 100644 --- a/packages/gitlab-mcp/src/utils/error-handler.ts +++ b/packages/gitlab-mcp/src/utils/error-handler.ts @@ -486,7 +486,7 @@ function detectTierRestriction( // Check for iterations if (tool === 'browse_iterations') { - if (!connectionManager.isFeatureAvailable('iterations')) { + if (connectionManager.isFeatureAvailable('iterations') === false) { return createRestrictionInfo('iterations', currentTier); } } @@ -494,7 +494,7 @@ function detectTierRestriction( // Check for group webhooks (tool name pattern) if ((tool === 'browse_webhooks' || tool === 'manage_webhook') && toolArgs?.scope === 'group') { // Group webhooks require Premium - check if we have serviceDesk (Premium feature) as proxy - if (!connectionManager.isFeatureAvailable('serviceDesk')) { + if (connectionManager.isFeatureAvailable('serviceDesk') === false) { return { feature: 'serviceDesk', // Using serviceDesk as proxy for Premium tier name: 'Group Webhooks', @@ -530,21 +530,21 @@ function checkWorkItemTypeRestriction( // Check for EPIC if (types.includes('EPIC')) { - if (!connectionManager.isFeatureAvailable('epics')) { + if (connectionManager.isFeatureAvailable('epics') === false) { return createRestrictionInfo('epics', currentTier); } } // Check for OBJECTIVE/KEY_RESULT (OKRs) if (types.includes('OBJECTIVE') || types.includes('KEY_RESULT')) { - if (!connectionManager.isFeatureAvailable('okrs')) { + if (connectionManager.isFeatureAvailable('okrs') === false) { return createRestrictionInfo('okrs', currentTier); } } // Check for REQUIREMENT if (types.includes('REQUIREMENT')) { - if (!connectionManager.isFeatureAvailable('requirements')) { + if (connectionManager.isFeatureAvailable('requirements') === false) { return createRestrictionInfo('requirements', currentTier); } } diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index 57c88af7..fe5146f6 100644 --- a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts @@ -76,7 +76,16 @@ jest.mock('../../src/entities/labels/registry', () => ({ jest.mock('../../src/services/ConnectionManager', () => ({ ConnectionManager: { getInstance: jest.fn().mockReturnValue({ - getInstanceInfo: jest.fn().mockReturnValue({ tier: 'free', version: '17.0.0' }), + getInstanceInfo: jest.fn().mockImplementation((url?: string) => { + const { currentNamespaceTier, hasNamespaceSubscriptions } = jest.requireActual( + '../../src/services/namespace-tier-context', + ); + const tier = + url && hasNamespaceSubscriptions(url) + ? (currentNamespaceTier(url)?.tier ?? null) + : 'free'; + return { tier, version: '17.0.0' }; + }), getTokenScopeInfo: jest.fn().mockReturnValue(null), getAdminInfo: jest.fn().mockReturnValue(null), getCurrentInstanceUrl: jest.fn().mockReturnValue('https://gitlab.example.com'), @@ -408,6 +417,80 @@ describe('RegistryManager', () => { }); describe('Tool Availability Filtering', () => { + // The same instance and schema mode must retain separate concurrent namespace views. + it('isolates namespace catalogs, dispatch and statistics through refresh', async () => { + const { runWithNamespaceTier } = jest.requireActual( + '../../src/services/namespace-tier-context', + ); + const core = require('../../src/entities/core/registry').coreToolRegistry; + const handler = jest.fn().mockResolvedValue({ ok: true }); + core.set('paid_namespace_tool', { + name: 'paid_namespace_tool', + description: 'Paid namespace tool', + inputSchema: { type: 'object' }, + requirements: { default: { tier: 'premium' } }, + handler, + }); + const url = 'https://gitlab.com'; + try { + registryManager.refreshCache(url); + const observe = (tier: 'free' | 'ultimate') => + runWithNamespaceTier(url, tier, async () => { + await Promise.resolve(); + const available = tier === 'ultimate'; + expect(registryManager.getAvailableToolNames(url).includes('paid_namespace_tool')).toBe( + available, + ); + expect( + registryManager + .getAllToolDefinitions(url) + .some((t) => t.name === 'paid_namespace_tool'), + ).toBe(available); + expect(registryManager.getFilterStats(url).available).toBe( + registryManager.getAllToolDefinitions(url).length, + ); + if (available) + expect(await registryManager.executeTool('paid_namespace_tool', {}, url)).toEqual({ + ok: true, + }); + else + await expect( + registryManager.executeTool('paid_namespace_tool', {}, url), + ).rejects.toThrow('not found'); + }); + await Promise.all([observe('free'), observe('ultimate')]); + registryManager.refreshCache(url); + await Promise.all([observe('ultimate'), observe('free')]); + expect(registryManager.hasToolHandler('paid_namespace_tool', url)).toBe(true); + expect(handler).toHaveBeenCalledTimes(2); + } finally { + core.delete('paid_namespace_tool'); + } + }); + + // A SaaS instance has no single subscription: an unscoped catalog cannot assume Free. + it('keeps paid SaaS tools discoverable without a known namespace subscription', () => { + const core = require('../../src/entities/core/registry').coreToolRegistry; + core.set('paid_namespace_tool', { + name: 'paid_namespace_tool', + description: 'Paid namespace tool', + inputSchema: { type: 'object' }, + requirements: { default: { tier: 'premium' } }, + handler: jest.fn(), + }); + try { + registryManager.refreshCache('https://gitlab.com'); + expect(registryManager.getAvailableToolNames('https://gitlab.com')).toContain( + 'paid_namespace_tool', + ); + expect(registryManager.getAvailableToolNames('https://gitlab.example.com')).not.toContain( + 'paid_namespace_tool', + ); + } finally { + core.delete('paid_namespace_tool'); + } + }); + it('should filter unavailable tools', () => { // ConnectionManager mock reports version 17.0.0; a tool requiring 99.0 // fails the real InstanceCapabilities version gate and is filtered out. diff --git a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts new file mode 100644 index 00000000..a3198712 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { installRooCode } from '../../../../src/cli/install/installers'; + +jest.mock('fs'); +jest.mock('../../../../src/cli/install/backup', () => ({ + createBackup: jest.fn(() => ({ created: false })), +})); + +const mockedFs = fs as jest.Mocked; + +describe('Roo Code global installation', () => { + const platform = process.platform; + const appData = process.env.APPDATA; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }); + if (appData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = appData; + jest.clearAllMocks(); + }); + + // Prevent successful installs into an unused home-level .roo file on any supported OS. + test.each([ + ['darwin', join(homedir(), 'Library/Application Support/Code/User/globalStorage')], + ['linux', join(homedir(), '.config/Code/User/globalStorage')], + ['win32', 'C:/Profiles/test/AppData/Roaming/Code/User/globalStorage'], + ])('writes the active default VS Code extension configuration on %s', (os, storage) => { + Object.defineProperty(process, 'platform', { value: os }); + process.env.APPDATA = 'C:/Profiles/test/AppData/Roaming'; + const expected = `${storage}/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; + const existing = { mcpServers: { other: { command: 'other-server' } } }; + mockedFs.existsSync.mockImplementation((path) => String(path) === expected); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(existing)); + const config = { command: 'node', args: ['server.js'], env: {} }; + + const result = installRooCode(config); + + expect(result.success).toBe(true); + expect(result.configPath).toBe(expected); + expect(mockedFs.writeFileSync).toHaveBeenCalledWith( + expected, + JSON.stringify( + { + mcpServers: { + other: existing.mcpServers.other, + gitlab: { command: 'node', args: ['server.js'] }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts new file mode 100644 index 00000000..5a7c225a --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts @@ -0,0 +1,110 @@ +/** Namespace selection must preserve independent subscriptions without duplicating capability rules. */ +import { + namespaceTargetsForArgs, + namespaceTargetsForScope, + needsNamespaceTier, + withNamespaceCapabilities, +} from '../../../src/configuration/namespace-capabilities'; +import { getNamespaceTier } from '../../../src/services/NamespaceTierDetector'; +import { currentNamespaceTier } from '../../../src/services/namespace-tier-context'; + +jest.mock('../../../src/services/NamespaceTierDetector'); +const lookup = jest.mocked(getNamespaceTier); +const url = 'https://gitlab.com'; +const observed = () => currentNamespaceTier(url)?.tier; + +describe('namespace capabilities', () => { + beforeEach(() => { + jest.resetAllMocks(); + lookup.mockImplementation(async (path) => ({ + tier: path.startsWith('paid') ? 'ultimate' : 'free', + cachedAt: new Date(), + })); + }); + + // Scope metadata and actual arguments must retain project/group ID namespaces. + it('extracts configured scopes and typed numeric targets', () => { + expect(namespaceTargetsForScope({ project: 'a/p', groups: ['b', 'c'] })).toEqual([ + { path: 'a/p', kind: 'project' }, + { path: 'b', kind: 'group' }, + { path: 'c', kind: 'group' }, + ]); + expect(namespaceTargetsForArgs({ project_id: '7', group_id: '7' })).toEqual([ + { path: '7', kind: 'project' }, + { path: '7', kind: 'group' }, + ]); + expect(namespaceTargetsForScope(undefined)).toEqual([]); + }); + + // Free-only tools do not pay for optional billing observations. + it('requests observations only for declared paid capabilities', () => { + expect(needsNamespaceTier(undefined)).toBe(false); + expect(needsNamespaceTier({ default: { tier: 'free' } })).toBe(false); + expect(needsNamespaceTier({ default: {}, parameters: { weight: { tier: 'premium' } } })).toBe( + true, + ); + expect(needsNamespaceTier({ default: {}, actions: { audit: { tier: 'ultimate' } } })).toBe( + true, + ); + }); + + // Descendants inherit one root plan; catalog union and cross-project execution differ. + it('deduplicates root observations and selects a catalog union', async () => { + const targets = [ + { path: 'free/p', kind: 'project' as const }, + { path: 'paid/p', kind: 'project' as const }, + { path: 'paid/nested/p', kind: 'project' as const }, + ]; + expect(await withNamespaceCapabilities(url, targets, true, observed)).toBe('ultimate'); + expect(lookup).toHaveBeenCalledTimes(2); + expect(await withNamespaceCapabilities(url, targets, false, observed)).toBeNull(); + expect(currentNamespaceTier(url)).toBeUndefined(); + }); + + // Concurrent chats on one host must not exchange a request-local tier or instance. + it('isolates concurrent calls and restores the caller context', async () => { + const call = (path: string) => + withNamespaceCapabilities(url, [{ path, kind: 'project' }], false, async () => { + await Promise.resolve(); + expect(currentNamespaceTier('https://other.example')).toBeUndefined(); + return observed(); + }); + expect(await Promise.all([call('free/p'), call('paid/p')])).toEqual(['free', 'ultimate']); + expect(observed()).toBeUndefined(); + }); + + // Missing plans cannot establish Free; stop queued optional reads after that fact. + it('bounds lookups and stops queued work on an unknown subscription', async () => { + lookup.mockResolvedValue({ tier: null, cachedAt: new Date() }); + const targets = Array.from({ length: 20 }, (_, i) => ({ + path: `team${i}/p`, + kind: 'project' as const, + })); + expect(await withNamespaceCapabilities(url, targets, true, observed)).toBeNull(); + expect(lookup).toHaveBeenCalledTimes(5); + }); + + // Opaque identities and invalid paths are not resolved by guessing a namespace. + it.each(['gid://gitlab/Project/7', '../outside'])( + 'leaves %s to its owning handler', + async (path) => { + expect( + await withNamespaceCapabilities(url, [{ path, kind: 'project' }], false, observed), + ).toBeNull(); + expect(lookup).not.toHaveBeenCalled(); + }, + ); + + // Self-hosted licenses and unexpected programming failures retain their existing paths. + it('does not query namespaces on self-hosted instances or conceal unexpected errors', async () => { + expect( + await withNamespaceCapabilities('https://gitlab.example', [], true, () => 'self-hosted'), + ).toBe('self-hosted'); + expect(lookup).not.toHaveBeenCalled(); + lookup.mockRejectedValue(new Error('unexpected failure')); + await expect( + withNamespaceCapabilities(url, [{ path: 'team', kind: 'group' }], false, observed), + ).rejects.toThrow('unexpected failure'); + expect(observed()).toBeUndefined(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 9ad2471f..f63c406e 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -282,6 +282,165 @@ describe('handlers', () => { mockSessionManager.setSessionInstanceUrl.mockReturnValue(undefined); }); + describe('namespace capabilities', () => { + const url = 'https://gitlab.com'; + let lookup: jest.SpyInstance; + + beforeEach(async () => { + const detector = await import('../../src/services/NamespaceTierDetector'); + lookup = jest + .spyOn(detector, 'getNamespaceTier') + .mockResolvedValue({ tier: 'ultimate', cachedAt: new Date() }); + mockSessionManager.getSessionInstanceUrl.mockReturnValue(url); + await setupHandlers(mockServer); + }); + + afterEach(() => { + lookup.mockRestore(); + jest.useRealTimers(); + }); + + // Catalog construction must observe the chat's scoped namespace on its selected host. + it('builds the catalog inside the selected namespace context', async () => { + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + mockSettings.set(`token:${url}`, { + accountKey: `token:${url}`, + version: 1, + updatedAt: 1, + settings: { scope: { type: 'project', path: 'paid/project', includeSubgroups: false } }, + }); + const tiers: unknown[] = []; + mockRegistryManager.getAllToolDefinitions.mockImplementationOnce(() => { + tiers.push(currentNamespaceTier(url)?.tier); + return []; + }); + await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(lookup).toHaveBeenCalledWith('paid/project', 'project'); + expect(tiers).toEqual(['ultimate']); + expect(currentNamespaceTier(url)).toBeUndefined(); + }); + + // Execution must bind each argument's namespace independently of another concurrent call. + it('dispatches concurrent paid-tool calls in their target namespace contexts', async () => { + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + lookup.mockImplementation(async (path: string) => ({ + tier: path === 'paid' ? 'ultimate' : 'free', + cachedAt: new Date(), + })); + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + const tiers: unknown[] = []; + const execute = mockRegistryManager.executeTool.getMockImplementation(); + mockRegistryManager.executeTool.mockImplementation(async () => { + await Promise.resolve(); + tiers.push(currentNamespaceTier(url)?.tier); + return { ok: true }; + }); + try { + await Promise.all( + ['free', 'paid'].map((group_id) => + runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { params: { name: 'browse_iterations', arguments: { action: 'list', group_id } } }, + { sessionId: `chat-${group_id}` }, + ), + ), + ), + ); + expect(tiers.sort()).toEqual(['free', 'ultimate']); + } finally { + if (execute) mockRegistryManager.executeTool.mockImplementation(execute); + } + }); + + // Discovery may inspect only namespaces within the operator's project intersection. + it('does not look up the plan of a configured project outside the operator list', async () => { + const { gitlab } = await import('../../src/utils/gitlab-api'); + const detector = await import('../../src/services/NamespaceTierDetector'); + const auth = jest.spyOn(detector, 'canResolveNamespaceTier').mockReturnValue(true); + const read = jest.spyOn(gitlab, 'get').mockResolvedValue({ + id: 1, + path_with_namespace: 'allowed/project', + name_with_namespace: 'Allowed / Project', + }); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.push('1'); + mockSettings.set(`token:${url}`, { + accountKey: `token:${url}`, + version: 1, + updatedAt: 1, + settings: { scope: { type: 'project', path: 'outside/project', includeSubgroups: false } }, + }); + try { + await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(lookup).not.toHaveBeenCalled(); + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith('projects/1'); + } finally { + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.splice(0); + read.mockRestore(); + auth.mockRestore(); + } + }); + + // Invalid paid-tool input must fail validation before an optional metadata read. + it('validates required arguments before namespace resolution', async () => { + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + const result = await runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { params: { name: 'browse_iterations', arguments: { action: 'list' } } }, + { sessionId: 'namespace-chat' }, + ), + ); + expect(result.isError).toBe(true); + expect(lookup).not.toHaveBeenCalled(); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + + // A metadata read finishing after the request deadline must never start a late operation. + it('does not execute a tool after namespace discovery exceeds the deadline', async () => { + let markStarted!: () => void; + let release!: (value: { tier: 'ultimate'; cachedAt: Date }) => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const metadata = new Promise<{ tier: 'ultimate'; cachedAt: Date }>((resolve) => { + release = resolve; + }); + lookup.mockImplementation(() => { + markStarted(); + return metadata; + }); + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + jest.useFakeTimers(); + const request = runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { + params: { name: 'browse_iterations', arguments: { action: 'list', group_id: 'paid' } }, + }, + { sessionId: 'namespace-chat' }, + ), + ); + await started; + await jest.advanceTimersByTimeAsync(101); + expect((await request).isError).toBe(true); + release({ tier: 'ultimate', cachedAt: new Date() }); + jest.useRealTimers(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + }); + describe('setupHandlers', () => { it('should initialize health monitor and set up request handlers', async () => { await setupHandlers(mockServer); diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts index 773bf70b..c7fdb790 100644 --- a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -4,6 +4,14 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'yaml'; +// Refresh downloads after the release workflow has finished uploading its assets. +test('docs rebuild after release publication completes', () => { + const workflow = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/docs.yml'), 'utf8'), + ); + expect(workflow.on.workflow_run.workflows).toContain('Release'); +}); + // Execute the production workflow step offline: component tags must produce usable asset versions. test.each(['13.1.0', '13.2.0'])( 'docs download resolves release %s from its component tag', diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts index c92f75b5..223ba6b1 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts @@ -77,6 +77,34 @@ function injectInstanceState( } describe('ConnectionManager Unit', () => { + // Instance-wide detection must not manufacture a subscription shared by SaaS namespaces. + it('projects request-local subscriptions without changing the stored connection', async () => { + const { runWithNamespaceTier } = await import('../../../src/services/namespace-tier-context'); + const manager = ConnectionManager.getInstance(); + const stored = { + version: '19.5.0', + tier: 'free' as const, + features: {} as any, + detectedAt: new Date(), + }; + injectInstanceState(manager, 'https://gitlab.com', { instanceInfo: stored }); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBeNull(); + await Promise.all( + ['free', 'ultimate'].map((tier) => + runWithNamespaceTier('https://gitlab.com', tier as 'free' | 'ultimate', async () => { + await Promise.resolve(); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBe(tier); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBe( + tier === 'ultimate', + ); + }), + ), + ); + expect(stored.tier).toBe('free'); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); + }); + beforeEach(() => { // Reset singleton instance for each test (ConnectionManager as unknown as { instance: null }).instance = null; diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts index e286af5a..c8a7284d 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts @@ -772,7 +772,7 @@ describe('ConnectionManager Enhanced Tests', () => { connectionManager.isFeatureAvailable( undefined as unknown as keyof import('../../../src/services/GitLabVersionDetector').GitLabInstanceInfo['features'], ), - ).toBe(undefined); + ).toBeNull(); }); it('should handle instance info with missing features', async () => { @@ -789,7 +789,7 @@ describe('ConnectionManager Enhanced Tests', () => { // Should handle gracefully without throwing expect(() => connectionManager.isFeatureAvailable('workItems')).not.toThrow(); - expect(connectionManager.isFeatureAvailable('workItems')).toBe(undefined); + expect(connectionManager.isFeatureAvailable('workItems')).toBeNull(); }); it('should handle parallel initialization attempts', async () => { diff --git a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts index 67edc9d6..ea01ae4d 100644 --- a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts @@ -24,6 +24,17 @@ const free17: CapabilityGate = { version: '17.0.0', tier: 'free' }; const premium17: CapabilityGate = { version: '17.0.0', tier: 'premium' }; const ultimate17: CapabilityGate = { version: '17.0.0', tier: 'ultimate' }; +// Missing billing visibility cannot stand for Free, while version/admin checks still apply. +test('an unknown namespace tier does not bypass version or admin requirements', () => { + const unknown = { version: '17.0.0', tier: null } as unknown as CapabilityGate; + expect(meetsRequirement({ tier: 'ultimate' }, unknown)).toBe(true); + expect(meetsRequirement({ tier: 'ultimate', minVersion: '18.0' }, unknown)).toBe(false); + expect(meetsRequirement({ requiresAdmin: true }, { ...unknown, adminModeActive: false })).toBe( + false, + ); + expect(getUnmetReason({ default: { tier: 'ultimate' } }, unknown)).toBeNull(); +}); + const reqs: ToolRequirements = { default: { tier: 'free', minVersion: '8.0' }, actions: { diff --git a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts index 8f2d2fc0..1c87f0b5 100644 --- a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts @@ -1,776 +1,235 @@ -/** - * Unit tests for NamespaceTierDetector - * - * Tests namespace tier detection, caching, and feature availability mapping. - * Mocks external dependencies (token context, fetch) for isolated unit testing. - */ - +/** REST subscription observations, credential isolation and bounded cache lifecycle. */ import { - getFeaturesForTier, clearNamespaceTierCache, getNamespaceTier, - isFeatureAvailable, getNamespaceTierCacheMetrics, } from '../../../src/services/NamespaceTierDetector'; import * as tokenContext from '../../../src/oauth/token-context'; import * as fetchUtils from '../../../src/utils/fetch'; +import { getGitLabBaseUrl } from '../../../src/utils/gitlab-base-url'; -// Mock dependencies jest.mock('../../../src/oauth/token-context'); jest.mock('../../../src/utils/fetch'); +jest.mock('../../../src/utils/gitlab-base-url'); jest.mock('../../../src/logger', () => ({ logDebug: jest.fn(), logWarn: jest.fn(), logError: jest.fn(), logInfo: jest.fn(), })); -jest.mock('../../../src/config', () => ({ - GITLAB_BASE_URL: 'https://gitlab.com', -})); - -const mockedGetTokenContext = tokenContext.getTokenContext as jest.MockedFunction< - typeof tokenContext.getTokenContext ->; -const mockedEnhancedFetch = fetchUtils.enhancedFetch as jest.MockedFunction< - typeof fetchUtils.enhancedFetch ->; +const mockedGetTokenContext = jest.mocked(tokenContext.getTokenContext); +const mockedEnhancedFetch = jest.mocked(fetchUtils.enhancedFetch); describe('NamespaceTierDetector', () => { beforeEach(() => { - // Clear all mocks and cache before each test jest.clearAllMocks(); clearNamespaceTierCache(); - }); - - describe('getFeaturesForTier', () => { - // Tests feature availability mapping for each tier level - - it('should return correct features for free tier', () => { - const features = getFeaturesForTier('free'); - - // Free tier has basic features - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - expect(features.snippets).toBe(true); - - // Free tier lacks premium features - expect(features.epics).toBe(false); - expect(features.iterations).toBe(false); - expect(features.roadmaps).toBe(false); - expect(features.healthStatus).toBe(false); - expect(features.weight).toBe(false); - expect(features.requirements).toBe(false); - - // Free tier lacks ultimate features - expect(features.okrs).toBe(false); - expect(features.securityDashboard).toBe(false); - }); - - it('should return correct features for premium tier', () => { - const features = getFeaturesForTier('premium'); - - // Premium has all free features - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - - // Premium has additional planning features - expect(features.epics).toBe(true); - expect(features.iterations).toBe(true); - expect(features.roadmaps).toBe(true); - expect(features.healthStatus).toBe(true); - expect(features.weight).toBe(true); - expect(features.multiLevelEpics).toBe(true); - expect(features.portfolioManagement).toBe(true); - expect(features.requirements).toBe(true); - expect(features.complianceFramework).toBe(true); - - // Premium lacks ultimate-only features - expect(features.okrs).toBe(false); - expect(features.securityDashboard).toBe(false); - }); - - it('should return correct features for ultimate tier', () => { - const features = getFeaturesForTier('ultimate'); - - // Ultimate has all features enabled - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - expect(features.epics).toBe(true); - expect(features.iterations).toBe(true); - expect(features.roadmaps).toBe(true); - expect(features.okrs).toBe(true); - expect(features.healthStatus).toBe(true); - expect(features.weight).toBe(true); - expect(features.multiLevelEpics).toBe(true); - expect(features.portfolioManagement).toBe(true); - expect(features.requirements).toBe(true); - expect(features.securityDashboard).toBe(true); - expect(features.complianceFramework).toBe(true); - }); - - it('should return a copy of features (not a reference)', () => { - // Verify immutability - modifying returned object should not affect internal state - const features1 = getFeaturesForTier('free'); - features1.issues = false; // Modify returned object - - const features2 = getFeaturesForTier('free'); - expect(features2.issues).toBe(true); // Original should be unchanged + jest.mocked(getGitLabBaseUrl).mockImplementation(() => mockedGetTokenContext()?.apiUrl ?? ''); + jest.mocked(fetchUtils.getAuthHeaders).mockImplementation((): Record => { + const token = mockedGetTokenContext()?.gitlabToken; + return token ? { Authorization: `Bearer ${token}` } : {}; }); }); - describe('clearNamespaceTierCache', () => { - // Tests cache clearing functionality for session-specific and global clearing - - it('should clear cache for specific session', async () => { - // Setup: Create entries for two sessions - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token-1', - gitlabUserId: 1, - gitlabUsername: 'user1', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - await getNamespaceTier('namespace-a'); - await getNamespaceTier('namespace-b'); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'token-2', - gitlabUserId: 2, - gitlabUsername: 'user2', - apiUrl: 'https://gitlab.com', - }); - - await getNamespaceTier('namespace-c'); - - // Verify initial state - let metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(3); - expect(metrics.entriesBySession.get('session-1')).toBe(2); - expect(metrics.entriesBySession.get('session-2')).toBe(1); - - // Clear only session-1 - clearNamespaceTierCache('session-1'); - - // Verify session-1 entries cleared, session-2 remains - metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(1); - expect(metrics.entriesBySession.get('session-1')).toBeUndefined(); - expect(metrics.entriesBySession.get('session-2')).toBe(1); - }); - - it('should clear all cache when no session specified', async () => { - // Setup: Create entries for multiple sessions - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token-1', - gitlabUserId: 1, - gitlabUsername: 'user1', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - await getNamespaceTier('namespace-a'); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'token-2', - gitlabUserId: 2, - gitlabUsername: 'user2', - apiUrl: 'https://gitlab.com', - }); - - await getNamespaceTier('namespace-b'); - - // Verify entries exist - expect(getNamespaceTierCacheMetrics().totalEntries).toBe(2); - - // Clear all - clearNamespaceTierCache(); + const context = { + sessionId: 'session-one', + gitlabToken: 'fixture-token', + gitlabUserId: 1, + gitlabUsername: 'fixture-user', + apiUrl: 'https://gitlab.com', + }; - // Verify all cleared - expect(getNamespaceTierCacheMetrics().totalEntries).toBe(0); - }); + beforeEach(() => { + mockedGetTokenContext.mockReturnValue({ ...context }); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'premium' })), + ); }); - describe('getNamespaceTier', () => { - // Tests tier detection from GraphQL API with caching behavior - - it('should return free tier when no token context available', async () => { - // Tests fallback behavior when OAuth context is not set - mockedGetTokenContext.mockReturnValue(undefined); - - const result = await getNamespaceTier('some-namespace'); - - expect(result.tier).toBe('free'); - expect(result.features.epics).toBe(false); - expect(result.cachedAt).toBeInstanceOf(Date); - - // Should not make API call - expect(mockedEnhancedFetch).not.toHaveBeenCalled(); - }); - - it('should use context apiUrl when available', async () => { - // Tests that apiUrl from context is used for GraphQL endpoint - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: 'https://custom-gitlab.example.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - await getNamespaceTier('some-namespace'); - - // Verify custom URL was used - expect(mockedEnhancedFetch).toHaveBeenCalledWith( - 'https://custom-gitlab.example.com/api/graphql', - expect.anything(), - ); - }); - - it('should query GraphQL and return correct tier for group namespace', async () => { - // Tests tier detection for a group with "ultimate" plan - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.example.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - namespace: { - id: 'gid://gitlab/Group/1', - fullPath: 'my-group', - plan: 'ultimate', - }, - }, - }), - } as Response); - - const result = await getNamespaceTier('my-group'); - - expect(result.tier).toBe('ultimate'); - expect(result.features.okrs).toBe(true); - expect(result.features.securityDashboard).toBe(true); - expect(result.cachedAt).toBeInstanceOf(Date); - - // Verify GraphQL call - expect(mockedEnhancedFetch).toHaveBeenCalledWith( - 'https://gitlab.example.com/api/graphql', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Authorization: 'Bearer test-token', - }), - }), - ); - }); - - it('should query GraphQL and return correct tier for project namespace', async () => { - // Tests tier detection for a project (uses group.plan from parent group) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - namespace: { - id: 'gid://gitlab/Project/42', - fullPath: 'my-group/my-project', - group: { - plan: 'premium', - }, - }, - }, - }), - } as Response); - - const result = await getNamespaceTier('my-group/my-project'); - - expect(result.tier).toBe('premium'); - expect(result.features.epics).toBe(true); - expect(result.features.okrs).toBe(false); // Premium doesn't have OKRs - }); - - it('should use cached result on subsequent calls', async () => { - // Tests that caching works - second call should not trigger API request - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - // First call - should hit API - const result1 = await getNamespaceTier('cached-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); - - // Second call - should use cache - const result2 = await getNamespaceTier('cached-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); // Still 1, no new call - - // Results should be identical - expect(result1.tier).toBe(result2.tier); - expect(result1.cachedAt.getTime()).toBe(result2.cachedAt.getTime()); - }); - - // A chat switched to a linked account keeps the connection's session id; the tier its - // account sees must not come from the previous account's or instance's cache entry. - it('keeps the tiers of different accounts of one session apart', async () => { - const corp = { - sessionId: 'session-1', - gitlabToken: 'corp-token', - gitlabUserId: 7, - gitlabUsername: 'alice', - apiUrl: 'https://git.corp.example.com', - }; - mockedEnhancedFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'ultimate' } } }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'free' } } }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'premium' } } }), - } as Response); - - mockedGetTokenContext.mockReturnValue(corp); - expect((await getNamespaceTier('group/project')).tier).toBe('ultimate'); - mockedGetTokenContext.mockReturnValue({ - ...corp, - gitlabToken: 'com-token', - gitlabUserId: 99, - apiUrl: 'https://gitlab.com', - }); - expect((await getNamespaceTier('group/project')).tier).toBe('free'); - mockedGetTokenContext.mockReturnValue({ ...corp, gitlabToken: 'bob-token', gitlabUserId: 8 }); - expect((await getNamespaceTier('group/project')).tier).toBe('premium'); - - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(3); - expect(getNamespaceTierCacheMetrics().entriesBySession.get('session-1')).toBe(3); - }); - - it('should expire cache after TTL (5 minutes)', async () => { - // Tests that cache entries are invalidated after TTL expires - jest.useFakeTimers(); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-ttl', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - // First call - caches the result - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); - - // Advance time by 4 minutes - cache should still be valid - jest.advanceTimersByTime(4 * 60 * 1000); - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); // Still cached - - // Advance time past 5 minute TTL (total 6 minutes) - jest.advanceTimersByTime(2 * 60 * 1000); - - // This call should trigger a new API request because cache expired - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); // New call after expiry - - jest.useRealTimers(); - }); - - it('should return free tier on API error', async () => { - // Tests graceful degradation when API call fails - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({}), - } as Response); - - const result = await getNamespaceTier('error-namespace'); - - expect(result.tier).toBe('free'); - expect(result.features.epics).toBe(false); - }); - - it('should return free tier on network error', async () => { - // Tests graceful degradation on network failures - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockRejectedValue(new Error('Network error')); - - const result = await getNamespaceTier('network-error-namespace'); - - expect(result.tier).toBe('free'); - }); - - it('should return free tier on GraphQL errors', async () => { - // Tests handling of GraphQL-level errors in response - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - errors: [{ message: 'Namespace not found' }], - data: { namespace: null }, - }), - } as Response); - - const result = await getNamespaceTier('nonexistent-namespace'); - - // When namespace not found, returns free tier - expect(result.tier).toBe('free'); - }); - - it('should normalize plan names correctly', async () => { - // Tests normalization of legacy plan names (gold, silver, bronze) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - // Test "gold" -> "ultimate" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'gold' } }, - }), - } as Response); - - let result = await getNamespaceTier('gold-plan-group'); - expect(result.tier).toBe('ultimate'); - - // Clear cache for next test - clearNamespaceTierCache(); - - // Test "silver" -> "premium" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'silver' } }, - }), - } as Response); - - result = await getNamespaceTier('silver-plan-group'); - expect(result.tier).toBe('premium'); - - clearNamespaceTierCache(); - - // Test "bronze" -> "premium" (bronze/starter maps to premium features) - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'bronze' } }, - }), - } as Response); - - result = await getNamespaceTier('bronze-plan-group'); - expect(result.tier).toBe('premium'); - - clearNamespaceTierCache(); - - // Test "starter" -> "premium" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'starter' } }, - }), - } as Response); - - result = await getNamespaceTier('starter-plan-group'); - expect(result.tier).toBe('premium'); - }); - - it('should return free tier for null/undefined plan', async () => { - // Tests handling of namespaces without a plan - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: null } }, - }), - } as Response); - - const result = await getNamespaceTier('no-plan-namespace'); - expect(result.tier).toBe('free'); - }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); }); - describe('getNamespaceTier with empty base URL', () => { - // Isolated tests for empty base URL scenario - it('should return free tier when both apiUrl and GITLAB_BASE_URL are empty', async () => { - // Use isolateModules to test with different config - await jest.isolateModulesAsync(async () => { - // Mock config with empty GITLAB_BASE_URL - jest.doMock('../../../src/config', () => ({ - GITLAB_BASE_URL: '', - })); - jest.doMock('../../../src/logger', () => ({ - logDebug: jest.fn(), - logWarn: jest.fn(), - logError: jest.fn(), - logInfo: jest.fn(), - })); - jest.doMock('../../../src/utils/fetch', () => ({ - enhancedFetch: jest.fn(), - })); - jest.doMock('../../../src/oauth/token-context', () => ({ - getTokenContext: jest.fn().mockReturnValue({ - sessionId: 'session-empty-url', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: '', // Empty apiUrl - }), - })); - - // Import fresh module with new mocks - const { getNamespaceTier: getNamespaceTierFresh } = - await import('../../../src/services/NamespaceTierDetector'); - - const result = await getNamespaceTierFresh('test-namespace'); - expect(result.tier).toBe('free'); - }); - }); + // Plan visibility is permission-dependent, so absence and failure never establish Free. + test.each([ + ['free', 'free'], + ['default', 'free'], + ['premium', 'premium'], + ['ultimate', 'ultimate'], + ['gold', 'ultimate'], + ['silver', 'premium'], + ['bronze', null], + ['starter', null], + ['PREMIUM', 'premium'], + ['premium_trial', 'premium'], + ['ultimate_trial', 'ultimate'], + ['opensource', 'ultimate'], + [null, null], + [undefined, null], + ['unrecognized-plan', null], + ])('normalizes the REST subscription %s to %s', async (plan, expected) => { + mockedEnhancedFetch.mockImplementation(async () => new Response(JSON.stringify({ plan }))); + expect((await getNamespaceTier('team')).tier).toBe(expected); }); - describe('isFeatureAvailable', () => { - // Tests feature availability checking for specific namespaces - - it('should return true for available features', async () => { - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - // Ultimate tier should have OKRs - const hasOkrs = await isFeatureAvailable('ultimate-group', 'okrs'); - expect(hasOkrs).toBe(true); - - // Ultimate tier should have security dashboard - const hasSecDash = await isFeatureAvailable('ultimate-group', 'securityDashboard'); - expect(hasSecDash).toBe(true); - }); - - it('should return false for unavailable features', async () => { - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'free' } }, - }), - } as Response); - - // Free tier should not have epics - const hasEpics = await isFeatureAvailable('free-group', 'epics'); - expect(hasEpics).toBe(false); - - // Free tier should not have OKRs - const hasOkrs = await isFeatureAvailable('free-group', 'okrs'); - expect(hasOkrs).toBe(false); - }); - - it('should return false for unknown features', async () => { - // Tests that unknown feature names return false (safe default) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-3', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - // Even ultimate tier returns false for unknown features - const hasUnknown = await isFeatureAvailable('any-group', 'nonExistentFeature'); - expect(hasUnknown).toBe(false); - }); + // Descendants share the root subscription and do not issue duplicate metadata requests. + it('uses the root REST entity and shares its observation with descendants', async () => { + const first = await getNamespaceTier('team/subgroup/project'); + expect(first.tier).toBe('premium'); + expect(first.cachedAt).toBeInstanceOf(Date); + expect((await getNamespaceTier('team/another')).tier).toBe('premium'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + expect(mockedEnhancedFetch).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ + method: 'GET', + headers: { Authorization: 'Bearer fixture-token' }, + }), + ); }); - describe('getNamespaceTierCacheMetrics', () => { - // Tests cache metrics reporting + // An account switch or token replacement must not reuse another credential's metadata. + it('separates accounts, credentials and instances within one session', async () => { + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ + ...context, + gitlabUserId: 2, + gitlabToken: 'second-token', + }); + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ ...context, gitlabToken: 'rotated-token' }); + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ + ...context, + apiUrl: 'https://gitlab.example.com/subpath', + }); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(4); + expect(mockedEnhancedFetch).toHaveBeenLastCalledWith( + 'https://gitlab.example.com/subpath/api/v4/namespaces/team', + expect.any(Object), + ); + }); - it('should return empty metrics for empty cache', () => { - const metrics = getNamespaceTierCacheMetrics(); + // Static-token connections use the transport's credential selection and isolate rotations. + it('uses the selected static credential when no OAuth context exists', async () => { + mockedGetTokenContext.mockReturnValue(undefined); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'static-one' }); + await getNamespaceTier('team'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'static-two' }); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + expect(mockedEnhancedFetch).toHaveBeenLastCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ headers: { 'PRIVATE-TOKEN': 'static-two' } }), + ); + }); - expect(metrics.totalEntries).toBe(0); - expect(metrics.entriesBySession.size).toBe(0); + // Cache invalidation is scoped to the exact session, including separator characters. + it('clears one session without discarding another session', async () => { + await getNamespaceTier('team'); + const second = 'session:with:separators'; + mockedGetTokenContext.mockReturnValue({ ...context, sessionId: second }); + await getNamespaceTier('team'); + expect(getNamespaceTierCacheMetrics().totalEntries).toBe(2); + clearNamespaceTierCache(context.sessionId); + expect(getNamespaceTierCacheMetrics().entriesBySession).toEqual(new Map([[second, 1]])); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + clearNamespaceTierCache(); + expect(getNamespaceTierCacheMetrics()).toEqual({ + totalEntries: 0, + entriesBySession: new Map(), }); + }); - it('should return correct metrics for populated cache', async () => { - // Setup: Add entries from multiple sessions - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-a', - gitlabToken: 'token-a', - gitlabUserId: 1, - gitlabUsername: 'user-a', - apiUrl: 'https://gitlab.com', - }); + // A changed subscription becomes visible after the documented five-minute TTL. + it('refreshes an expired observation', async () => { + jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') }); + await getNamespaceTier('team'); + jest.setSystemTime(new Date('2026-01-01T00:05:00.001Z')); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'ultimate' })), + ); + expect((await getNamespaceTier('team')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + }); - await getNamespaceTier('ns1'); - await getNamespaceTier('ns2'); - await getNamespaceTier('ns3'); + // Missing context must not trigger an unauthenticated request or claim a Free subscription. + it('returns unknown without usable authentication', async () => { + mockedGetTokenContext.mockReturnValue({ ...context, gitlabToken: '' }); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-b', - gitlabToken: 'token-b', - gitlabUserId: 2, - gitlabUsername: 'user-b', - apiUrl: 'https://gitlab.com', - }); + // Neither an HTTP error nor malformed provider output constitutes subscription evidence. + test.each([403, 404, 429, 500])('returns unknown for metadata HTTP %s', async (status) => { + mockedEnhancedFetch.mockImplementation(async () => new Response('fixture error', { status })); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }); - await getNamespaceTier('ns4'); - await getNamespaceTier('ns5'); + it('returns unknown for a network failure', async () => { + mockedEnhancedFetch.mockRejectedValue(new Error('fixture network failure')); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }); - // Verify metrics - const metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(5); - expect(metrics.entriesBySession.get('session-a')).toBe(3); - expect(metrics.entriesBySession.get('session-b')).toBe(2); - }); + it('does not log malformed provider content', async () => { + mockedEnhancedFetch.mockImplementation( + async () => new Response('fixture-secret-must-not-appear'), + ); + expect((await getNamespaceTier('team')).tier).toBeNull(); + const warnings = jest.requireMock('../../../src/logger').logWarn.mock.calls; + expect(JSON.stringify(warnings)).not.toContain('fixture-secret-must-not-appear'); + }); - it('should correctly count entries by session ID', async () => { - // Tests that sessionId is correctly extracted from cache keys - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'free' } }, - }), - } as Response); + // Numeric project IDs and group IDs occupy distinct spaces on the same instance. + it('resolves and caches a numeric project independently of a group with the same ID', async () => { + mockedEnhancedFetch.mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/projects/7')) + return new Response(JSON.stringify({ path_with_namespace: 'paid/project' })); + if (url.includes('/groups/7?with_projects=false')) + return new Response(JSON.stringify({ full_path: 'free-team/subgroup' })); + return new Response(JSON.stringify({ plan: url.endsWith('/paid') ? 'ultimate' : 'free' })); + }); + expect((await getNamespaceTier('7', 'project')).tier).toBe('ultimate'); + expect((await getNamespaceTier('7', 'group')).tier).toBe('free'); + expect((await getNamespaceTier('7', 'project')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(4); + }); - mockedGetTokenContext.mockReturnValue({ - sessionId: 'complex-session-id-with-colons:and:special:chars', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: 'https://gitlab.com', - }); + // The existing namespace convention tries groups first, falling through only on 404. + it('resolves an ambiguous numeric namespace in group-first order', async () => { + mockedEnhancedFetch + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ path_with_namespace: 'team/project' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('7', 'either')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://gitlab.com/api/v4/groups/7?with_projects=false', + 'https://gitlab.com/api/v4/projects/7', + 'https://gitlab.com/api/v4/namespaces/team', + ]); + }); - await getNamespaceTier('test-namespace'); + // Optional metadata cannot grow without bound when many different targets are requested. + it('evicts old observations at the cache capacity', async () => { + await getNamespaceTier('oldest'); + await Promise.all( + Array.from({ length: 1024 }, (_, index) => getNamespaceTier('team-' + index)), + ); + expect(getNamespaceTierCacheMetrics().totalEntries).toBeLessThanOrEqual(1024); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'ultimate' })), + ); + expect((await getNamespaceTier('oldest')).tier).toBe('ultimate'); + }); - const metrics = getNamespaceTierCacheMetrics(); - // Entries are grouped by session id, so separators inside the id do not matter. - expect(metrics.totalEntries).toBe(1); - expect(metrics.entriesBySession.get('complex-session-id-with-colons:and:special:chars')).toBe( - 1, - ); - }); + // Invalid path syntax must be rejected before a metadata resolver sees it. + it('rejects path traversal before network access', async () => { + await expect(getNamespaceTier('../team')).rejects.toThrow('dot segment'); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts index 2d641107..fc9e2f47 100644 --- a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts @@ -50,6 +50,22 @@ describe('WidgetAvailability', () => { }); describe('widget availability by tier', () => { + // Missing billing visibility must not reject a supported widget or bypass its version gate. + it('keeps unknown namespace tiers distinct from Free', () => { + mockConnectionManager.getInstanceInfo.mockReturnValue({ + ...mockInstanceInfoFree, + tier: null, + }); + expect(WidgetAvailability.isWidgetAvailable(WorkItemWidgetTypes.WEIGHT)).toBe(true); + expect(WidgetAvailability.validateWidgetParams({ weight: 1 })).toBeNull(); + mockConnectionManager.getInstanceInfo.mockReturnValue({ + ...mockInstanceInfoFree, + version: '16.0', + tier: null, + }); + expect(WidgetAvailability.isWidgetAvailable(WorkItemWidgetTypes.CUSTOM_FIELDS)).toBe(false); + }); + describe('free tier widgets', () => { beforeEach(() => { mockConnectionManager.getInstanceInfo.mockReturnValue(mockInstanceInfoFree); diff --git a/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts b/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts new file mode 100644 index 00000000..750f12b4 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts @@ -0,0 +1,44 @@ +import { + getNamespaceTier, + clearNamespaceTierCache, +} from '../../../src/services/NamespaceTierDetector'; +import { getTokenContext } from '../../../src/oauth/token-context'; +import { enhancedFetch } from '../../../src/utils/fetch'; +import { getAuthHeaders } from '../../../src/utils/fetch'; +import { getGitLabBaseUrl } from '../../../src/utils/gitlab-base-url'; + +jest.mock('../../../src/oauth/token-context'); +jest.mock('../../../src/utils/fetch'); +jest.mock('../../../src/utils/gitlab-base-url'); + +beforeEach(() => { + jest.clearAllMocks(); + clearNamespaceTierCache(); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(getAuthHeaders).mockReturnValue({ Authorization: 'Bearer fixture-token' }); + jest.mocked(getTokenContext).mockReturnValue({ + sessionId: 'session', + gitlabUserId: 1, + gitlabUsername: 'test', + gitlabToken: 'fixture-token', + apiUrl: 'https://gitlab.com', + }); +}); + +// A billing field omitted for a non-owner must not disable paid namespace tools. +test('an undisclosed plan remains unknown instead of Free', async () => { + jest + .mocked(enhancedFetch) + .mockResolvedValue(new Response(JSON.stringify({ id: 1, full_path: 'team' }))); + expect((await getNamespaceTier('team')).tier).toBeNull(); +}); + +// The real REST entity has no GraphQL data.namespace envelope. +test('reads the root subscription through the GitLab 16 REST contract', async () => { + jest.mocked(enhancedFetch).mockResolvedValue(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('team/subgroup/project')).tier).toBe('ultimate'); + expect(enhancedFetch).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ method: 'GET' }), + ); +}); From 8ba2f7c901502bbf5cd8f07776eec8fcc4fe1a0b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 10:15:09 +0200 Subject: [PATCH 03/22] test: type unknown capability fixtures Use the nullable capability contract directly in the regression fixture. --- .../gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts index ea01ae4d..f01c5f14 100644 --- a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts @@ -26,7 +26,7 @@ const ultimate17: CapabilityGate = { version: '17.0.0', tier: 'ultimate' }; // Missing billing visibility cannot stand for Free, while version/admin checks still apply. test('an unknown namespace tier does not bypass version or admin requirements', () => { - const unknown = { version: '17.0.0', tier: null } as unknown as CapabilityGate; + const unknown: CapabilityGate = { version: '17.0.0', tier: null }; expect(meetsRequirement({ tier: 'ultimate' }, unknown)).toBe(true); expect(meetsRequirement({ tier: 'ultimate', minVersion: '18.0' }, unknown)).toBe(false); expect(meetsRequirement({ requiresAdmin: true }, { ...unknown, adminModeActive: false })).toBe( From 7ea6d65dfb11e9788b369203dc80bf1b85f5d088 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:17:31 +0200 Subject: [PATCH 04/22] fix: encode namespace metadata identifiers Accept a fixed metadata collection and encode each identifier at the authenticated REST request boundary. Keep namespace key selection and test setup ordering explicit. Refs #639 --- .../configuration/namespace-capabilities.ts | 9 +++--- .../src/services/NamespaceTierDetector.ts | 30 ++++++++----------- .../unit/services/ConnectionManager.test.ts | 10 +++---- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts index 2acf1d4a..e0535c1f 100644 --- a/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts +++ b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts @@ -67,11 +67,10 @@ function distinctRoots(targets: readonly NamespaceTarget[]): NamespaceTarget[] | return null; } const slash = path.indexOf('/'); - const key = /^\d+$/.test(path) - ? `${target.kind}:${path}` - : slash < 0 - ? path - : path.slice(0, slash); + let key: string; + if (/^\d+$/.test(path)) key = `${target.kind}:${path}`; + else if (slash < 0) key = path; + else key = path.slice(0, slash); unique.set(key, target); } return [...unique.values()]; diff --git a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts index 947b1eed..2bc480e8 100644 --- a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts +++ b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts @@ -111,15 +111,20 @@ export function clearNamespaceTierCache(sessionId?: string): void { } async function readMetadata( - path: string, + collection: 'namespaces' | 'projects' | 'groups', + id: string, caller: Identity, limit: number, ): Promise<{ status: number; value: unknown }> { - const response = await enhancedFetch(`${caller.baseUrl}/api/v4/${path}`, { - method: 'GET', - headers: caller.headers, - rateLimitBaseUrl: caller.baseUrl, - }); + const query = collection === 'groups' ? '?with_projects=false' : ''; + const response = await enhancedFetch( + `${caller.baseUrl}/api/v4/${collection}/${encodeApiSegment(id)}${query}`, + { + method: 'GET', + headers: caller.headers, + rateLimitBaseUrl: caller.baseUrl, + }, + ); if (!response.ok) { await response.body?.cancel(); logWarn('Namespace subscription metadata is unavailable', { status: response.status }); @@ -150,12 +155,7 @@ async function numericTargetPath( caller: Identity, ): Promise { const collection = kind === 'project' ? 'projects' : 'groups'; - const query = collection === 'groups' ? '?with_projects=false' : ''; - const { status, value } = await readMetadata( - `${collection}/${encodeApiSegment(id)}${query}`, - caller, - 1024 * 1024, - ); + const { status, value } = await readMetadata(collection, id, caller, 1024 * 1024); if (kind === 'either' && status === 404) return numericTargetPath(id, 'project', caller); return stringField(value, kind === 'project' ? 'path_with_namespace' : 'full_path'); } @@ -186,11 +186,7 @@ export async function getNamespaceTier( const namespace = path === null ? null : rootOf(path); if (namespace === null) observation = unknown(); else { - const { value } = await readMetadata( - `namespaces/${encodeApiSegment(namespace)}`, - caller, - 64 * 1024, - ); + const { value } = await readMetadata('namespaces', namespace, caller, 64 * 1024); const plan = stringField(value, 'plan'); const tier = plan === null ? undefined : PLAN_TIERS.get(plan.toLowerCase()); observation = { tier: tier ?? null, cachedAt: new Date() }; diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts index 223ba6b1..84005553 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts @@ -77,6 +77,11 @@ function injectInstanceState( } describe('ConnectionManager Unit', () => { + beforeEach(() => { + // Reset singleton instance for each test + (ConnectionManager as unknown as { instance: null }).instance = null; + }); + // Instance-wide detection must not manufacture a subscription shared by SaaS namespaces. it('projects request-local subscriptions without changing the stored connection', async () => { const { runWithNamespaceTier } = await import('../../../src/services/namespace-tier-context'); @@ -105,11 +110,6 @@ describe('ConnectionManager Unit', () => { expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); }); - beforeEach(() => { - // Reset singleton instance for each test - (ConnectionManager as unknown as { instance: null }).instance = null; - }); - describe('singleton pattern', () => { it('should return the same instance on multiple calls', () => { const instance1 = ConnectionManager.getInstance(); From 487f2790ebda143c42471591be4c87fd6413b903 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:36:16 +0200 Subject: [PATCH 05/22] docs: correct client configuration examples Use a password input for the VS Code workspace example and distinguish client-specific formats in the manual and visible quick-start instructions. --- packages/gitlab-mcp/docs/clients/vscode.md | 13 ++++++++++++- .../gitlab-mcp/docs/guide/installation/manual.md | 11 +++++++++-- packages/gitlab-mcp/docs/guide/quick-start.md | 7 ++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/gitlab-mcp/docs/clients/vscode.md b/packages/gitlab-mcp/docs/clients/vscode.md index 1963ebfc..04bcf6d8 100644 --- a/packages/gitlab-mcp/docs/clients/vscode.md +++ b/packages/gitlab-mcp/docs/clients/vscode.md @@ -48,13 +48,21 @@ Create or edit `.vscode/mcp.json` in your project root: ```json { + "inputs": [ + { + "type": "promptString", + "id": "gitlab-token", + "description": "GitLab access token", + "password": true + } + ], "servers": { "gitlab": { "type": "stdio", "command": "npx", "args": ["-y", "@structured-world/gitlab-mcp"], "env": { - "GITLAB_TOKEN": "glpat-xxxxxxxxxxxxxxxxxxxx", + "GITLAB_TOKEN": "${input:gitlab-token}", "GITLAB_API_URL": "https://gitlab.com" } } @@ -62,6 +70,9 @@ Create or edit `.vscode/mcp.json` in your project root: } ``` +VS Code prompts for the token without writing its value into this workspace file. +Do not replace the input reference with a literal token in a committed configuration. + ## Remote Server Connect to a running GitLab MCP HTTP server: diff --git a/packages/gitlab-mcp/docs/guide/installation/manual.md b/packages/gitlab-mcp/docs/guide/installation/manual.md index 7bd8145c..bc371bad 100644 --- a/packages/gitlab-mcp/docs/guide/installation/manual.md +++ b/packages/gitlab-mcp/docs/guide/installation/manual.md @@ -13,7 +13,10 @@ For users who prefer full control over their setup, you can configure GitLab MCP ## Configuration Format -All MCP clients use a JSON configuration with the same structure: +Configuration formats depend on the client. The JSON examples below use the +`mcpServers` format accepted by Claude Desktop and Cursor. VS Code workspace +configuration uses `servers` in `.vscode/mcp.json`; Codex manual registration uses +TOML. Follow the corresponding [client guide](/clients/) before copying an example. ```json { @@ -46,7 +49,11 @@ See [Supported Clients](/clients/) for the config file location specific to your ### 3. Edit the Configuration -Add the `gitlab` entry to the `mcpServers` object in your config file. If the file doesn't exist, create it with the full JSON structure above. +For the clients using the format above, add `gitlab` under `mcpServers` and preserve +any existing entries. If the file does not exist, create the full structure. +For VS Code, use its [workspace configuration example](/clients/vscode#configuration), +including the secure token input. For Codex, follow the +[Codex installation guide](/guide/installation/codex). ### 4. Set Environment Variables diff --git a/packages/gitlab-mcp/docs/guide/quick-start.md b/packages/gitlab-mcp/docs/guide/quick-start.md index d8ff0580..37dfb3ec 100644 --- a/packages/gitlab-mcp/docs/guide/quick-start.md +++ b/packages/gitlab-mcp/docs/guide/quick-start.md @@ -38,7 +38,12 @@ See the [step-by-step authentication guide](/guide/authentication) for detailed ## 2. Configure Your MCP Client -Add to your MCP client configuration: +The example below is for Claude Desktop and Cursor. VS Code workspace configuration +uses `servers` instead of `mcpServers`; follow the [VS Code guide](/clients/vscode). +For Codex, use the [plugin or manual setup guide](/guide/installation/codex). +Other clients have [their own configuration formats](/clients/). + +Add the following to a compatible client's configuration: ```json { From bf15e9ff11981532d3d19ae9fb3f72e334c161ad Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:36:49 +0200 Subject: [PATCH 06/22] docs(roo): keep tokens outside project files --- packages/gitlab-mcp/docs/clients/roo-code.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/docs/clients/roo-code.md b/packages/gitlab-mcp/docs/clients/roo-code.md index 94066615..cc827b54 100644 --- a/packages/gitlab-mcp/docs/clients/roo-code.md +++ b/packages/gitlab-mcp/docs/clients/roo-code.md @@ -56,7 +56,7 @@ Add to the configuration file opened by Roo Code: "command": "npx", "args": ["-y", "@structured-world/gitlab-mcp"], "env": { - "GITLAB_TOKEN": "glpat-xxxxxxxxxxxxxxxxxxxx", + "GITLAB_TOKEN": "${env:GITLAB_TOKEN}", "GITLAB_API_URL": "https://gitlab.com" } } @@ -64,6 +64,10 @@ Add to the configuration file opened by Roo Code: } ``` +Set `GITLAB_TOKEN` in the environment of the VS Code process before starting Roo +Code. Roo resolves `${env:GITLAB_TOKEN}` from that environment. Do not replace the +reference with a literal token in `.roo/mcp.json` or commit credentials to Git. + ## Remote Server Connect to a running GitLab MCP HTTP server: From c2680d9c6b84becdf44c3458ecf0b88bbfca6378 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:37:20 +0200 Subject: [PATCH 07/22] docs: link current GitLab.com rate limits --- packages/gitlab-mcp/docs/configuration/rate-limiting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/docs/configuration/rate-limiting.md b/packages/gitlab-mcp/docs/configuration/rate-limiting.md index 45a0eae7..b955b44d 100644 --- a/packages/gitlab-mcp/docs/configuration/rate-limiting.md +++ b/packages/gitlab-mcp/docs/configuration/rate-limiting.md @@ -108,7 +108,7 @@ rateLimit: ``` GitLab.com has its own endpoint- and account-specific rate limits. Check the -[current GitLab.com limits](https://docs.gitlab.com/user/gitlab_com/#gitlabcom-specific-rate-limits) +[current GitLab.com limits](https://docs.gitlab.com/user/gitlab_com/rate_limits/) and reduce concurrency when the server returns 429. ### Self-Hosted (Production) From 253fda852d3e86c4fd6d64d22b216e6f307fae99 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:37:23 +0200 Subject: [PATCH 08/22] docs(codex): list archive check prerequisites --- packages/gitlab-mcp/docs/guides/codex-qualification.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/docs/guides/codex-qualification.md b/packages/gitlab-mcp/docs/guides/codex-qualification.md index cae7e92d..ffc68431 100644 --- a/packages/gitlab-mcp/docs/guides/codex-qualification.md +++ b/packages/gitlab-mcp/docs/guides/codex-qualification.md @@ -42,7 +42,12 @@ these results do not qualify the unreleased 13.2.0 package. ## Automated package and lifecycle checks -From `packages/gitlab-mcp`, after installing workspace dependencies: +This qualification command requires Node.js 24+, workspace dependencies, a Codex +CLI executable and `unzip` available on `PATH`. The `unzip` requirement also applies +when running qualification on Windows; it is separate from extracting the release +ZIP through a desktop file manager. + +From `packages/gitlab-mcp`: ```bash yarn build From 0e3beb60e99609c1006bb3ed0f8294187f3c714e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:40:37 +0200 Subject: [PATCH 09/22] fix(roo): honor the Linux config directory Resolve the standard VS Code profile from XDG_CONFIG_HOME when set. The regression test preserves and updates the configuration in the relocated directory. --- packages/gitlab-mcp/docs/clients/roo-code.md | 3 +++ packages/gitlab-mcp/src/cli/install/types.ts | 10 +++++-- .../tests/unit/cli/install/roo-code.test.ts | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/gitlab-mcp/docs/clients/roo-code.md b/packages/gitlab-mcp/docs/clients/roo-code.md index cc827b54..4cc999cf 100644 --- a/packages/gitlab-mcp/docs/clients/roo-code.md +++ b/packages/gitlab-mcp/docs/clients/roo-code.md @@ -22,6 +22,9 @@ configuration for the same server name. For the standard VS Code profile, automatic setup writes the extension's global `settings/mcp_settings.json`: +On Linux, the standard profile is under `XDG_CONFIG_HOME/Code` when the variable +is nonempty, or `~/.config/Code` otherwise. + ```bash npx @structured-world/gitlab-mcp install --roo-code ``` diff --git a/packages/gitlab-mcp/src/cli/install/types.ts b/packages/gitlab-mcp/src/cli/install/types.ts index 85533c1a..aad705b0 100644 --- a/packages/gitlab-mcp/src/cli/install/types.ts +++ b/packages/gitlab-mcp/src/cli/install/types.ts @@ -194,8 +194,14 @@ export const CLIENT_METADATA: Record = { '~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', win32: '%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', - linux: - '~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', + get linux(): string { + // VS Code uses XDG_CONFIG_HOME, with ~/.config as its standard Linux default. + // https://github.com/microsoft/vscode/blob/main/src/vs/platform/environment/node/userDataPath.ts + const root = process.env.XDG_CONFIG_HOME; + if (root) + return `${root}/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; + return '~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + }, }, supportsCliInstall: false, detectionMethod: 'config-file', diff --git a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts index a3198712..55262d35 100644 --- a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts @@ -13,14 +13,41 @@ const mockedFs = fs as jest.Mocked; describe('Roo Code global installation', () => { const platform = process.platform; const appData = process.env.APPDATA; + const configHome = process.env.XDG_CONFIG_HOME; + + beforeEach(() => { + delete process.env.XDG_CONFIG_HOME; + }); afterEach(() => { Object.defineProperty(process, 'platform', { value: platform }); if (appData === undefined) delete process.env.APPDATA; else process.env.APPDATA = appData; + if (configHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = configHome; jest.clearAllMocks(); }); + // A relocated VS Code config directory must be the file the installer preserves and updates. + it('honors the Linux XDG configuration directory', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.XDG_CONFIG_HOME = '/custom/config'; + const expected = + '/custom/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + mockedFs.existsSync.mockImplementation((path) => String(path) === expected); + mockedFs.readFileSync.mockReturnValue( + JSON.stringify({ mcpServers: { other: { command: 'other-server' } } }), + ); + + const result = installRooCode({ command: 'node', args: ['server.js'], env: {} }); + + expect(result.success).toBe(true); + expect(result.configPath).toBe(expected); + const written = JSON.parse(mockedFs.writeFileSync.mock.calls[0][1] as string); + expect(written.mcpServers.other).toEqual({ command: 'other-server' }); + expect(written.mcpServers.gitlab).toEqual({ command: 'node', args: ['server.js'] }); + }); + // Prevent successful installs into an unused home-level .roo file on any supported OS. test.each([ ['darwin', join(homedir(), 'Library/Application Support/Code/User/globalStorage')], From 967e1aa681ef8337d3764ec122b4522eb9de13be Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:45:05 +0200 Subject: [PATCH 10/22] fix(discovery): contain optional metadata errors Keep operator-project expansion local to subscription target selection. Preserve configured schema policy and return the unknown-tier catalog when optional metadata is unavailable, without logging provider error text. Include regression coverage for failed metadata reads and disjoint project scopes. --- packages/gitlab-mcp/src/handlers.ts | 12 ++++-- .../gitlab-mcp/tests/unit/handlers.test.ts | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/gitlab-mcp/src/handlers.ts b/packages/gitlab-mcp/src/handlers.ts index e323535a..0541638c 100644 --- a/packages/gitlab-mcp/src/handlers.ts +++ b/packages/gitlab-mcp/src/handlers.ts @@ -685,10 +685,16 @@ async function withoutRestrictedTools< const catalog = await runWithCaller(caller, async () => { let targets = namespaceTargetsForScope(policy.scope); if (hasNamespaceSubscriptions(caller.instanceUrl) && GITLAB_ALLOWED_PROJECT_IDS.length > 0) { + targets = []; if (canResolveNamespaceTier(caller.instanceUrl)) { - policy = await withOperatorProjects(policy); - targets = namespaceTargetsForScope(policy.scope); - } else targets = []; + try { + const operatorPolicy = await withOperatorProjects(policy); + targets = namespaceTargetsForScope(operatorPolicy.scope); + } catch { + // Billing discovery is optional; execution still verifies operator restrictions. + logWarn('Could not resolve operator projects for namespace discovery'); + } + } } return withNamespaceCapabilities(caller.instanceUrl, targets, true, tools); }); diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index f63c406e..10353b52 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -4,6 +4,7 @@ */ import { setImmediate } from 'node:timers'; +import { inspect } from 'node:util'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, @@ -375,10 +376,11 @@ describe('handlers', () => { settings: { scope: { type: 'project', path: 'outside/project', includeSubgroups: false } }, }); try { - await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + const result = await getRegisteredHandler(mockServer, ListToolsRequestSchema)( {}, { sessionId: 'namespace-chat' }, ); + expect(result.tools).toEqual([expect.objectContaining({ name: 'test_tool' })]); expect(lookup).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(1); expect(read).toHaveBeenCalledWith('projects/1'); @@ -389,6 +391,42 @@ describe('handlers', () => { } }); + // Optional operator metadata must not take down discovery or leak provider diagnostics. + it('keeps discovery available when operator project metadata fails', async () => { + const detector = await import('../../src/services/NamespaceTierDetector'); + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + const auth = jest.spyOn(detector, 'canResolveNamespaceTier').mockReturnValue(true); + const read = jest.spyOn(gitlab, 'get').mockRejectedValue(new Error('provider-secret')); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.push('1'); + const tiers: unknown[] = []; + mockRegistryManager.getAllToolDefinitions.mockImplementation(() => { + tiers.push(currentNamespaceTier(url)?.tier); + return [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + }); + try { + const result = await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(result.tools).toEqual([expect.objectContaining({ name: 'test_tool' })]); + expect(tiers).toEqual([null]); + expect(lookup).not.toHaveBeenCalled(); + const { logWarn } = await import('../../src/logger'); + expect(inspect(jest.mocked(logWarn).mock.calls)).not.toContain('provider-secret'); + } finally { + mockRegistryManager.getAllToolDefinitions.mockReset(); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.splice(0); + read.mockRestore(); + auth.mockRestore(); + } + }); + // Invalid paid-tool input must fail validation before an optional metadata read. it('validates required arguments before namespace resolution', async () => { mockRegistryManager.getTool.mockReturnValue({ From b5334965492a015a5ec2990a311655b9fc0c7d0b Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:46:50 +0200 Subject: [PATCH 11/22] test: use typed connection feature fixtures --- .../gitlab-mcp/tests/unit/services/ConnectionManager.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts index 84005553..ae972a48 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts @@ -4,6 +4,7 @@ */ import { ConnectionManager, type InstanceState } from '../../../src/services/ConnectionManager'; +import { GitLabVersionDetector } from '../../../src/services/GitLabVersionDetector'; import { runWithCaller } from '../../../src/configuration/caller'; // Mock isOAuthEnabled and getGitLabApiUrlFromContext @@ -89,7 +90,7 @@ describe('ConnectionManager Unit', () => { const stored = { version: '19.5.0', tier: 'free' as const, - features: {} as any, + features: GitLabVersionDetector.determineFeatures('19.5.0', 'free'), detectedAt: new Date(), }; injectInstanceState(manager, 'https://gitlab.com', { instanceInfo: stored }); From d80cd962be0d72cf096ed8520051e484b25a0a28 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:50:43 +0200 Subject: [PATCH 12/22] test: isolate Linux publishing contracts Run the Bash download regression as a required Linux Quality Checks step. Keep portable unit tests free of shell prerequisites and preserve both component-version download assertions in the dedicated workflow test. --- .github/workflows/ci-cd.yml | 4 ++ TESTING.md | 7 +++ .../tests/scripts/docs-download.test.mjs | 58 +++++++++++++++++++ .../tests/unit/release/docs-download.test.ts | 53 +---------------- 4 files changed, 70 insertions(+), 52 deletions(-) create mode 100644 packages/gitlab-mcp/tests/scripts/docs-download.test.mjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 0e3b8b36..8f7e2528 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -56,6 +56,10 @@ jobs: run: yarn lint working-directory: packages/gitlab-mcp + - name: Verify documentation release downloads + run: node --test tests/scripts/docs-download.test.mjs + working-directory: packages/gitlab-mcp + - name: Run tests with coverage run: yarn test:cov working-directory: packages/gitlab-mcp diff --git a/TESTING.md b/TESTING.md index 45ddc52f..22e63c72 100644 --- a/TESTING.md +++ b/TESTING.md @@ -66,6 +66,13 @@ Run these from `packages/gitlab-mcp`: | `yarn test:integration` | Integration suites in lifecycle order | Protected `.env.test`, designated test namespace | | `yarn test:all` | Unit and integration suites | The same integration environment | +The Linux publishing-workflow regression runs separately from the portable unit +suite and requires Bash. The required Quality Checks job executes it with: + +```bash +node --test tests/scripts/docs-download.test.mjs +``` + ### Complete Unit and Integration Test Suite ```bash yarn test:all diff --git a/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs b/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs new file mode 100644 index 00000000..f141e35b --- /dev/null +++ b/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import test from 'node:test'; +import { parse } from 'yaml'; + +// Execute the Linux publishing workflow's Bash step, independently of portable unit tests. +for (const version of ['13.1.0', '13.2.0']) { + test(`docs download resolves component release ${version}`, () => { + const workflow = parse( + readFileSync(new URL('../../../../.github/workflows/docs.yml', import.meta.url), 'utf8'), + ); + const step = workflow.jobs.build.steps.find((candidate) => candidate.id === 'mcpb'); + const root = mkdtempSync(join(tmpdir(), 'gitlab-docs-release-')); + const bin = join(root, 'bin'); + const output = join(root, 'output'); + mkdirSync(bin); + writeFileSync( + join(bin, 'gh'), + `#!${process.execPath} +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +if (args[0] !== 'release') throw new Error('Unexpected GitHub request'); +if (args[1] === 'view') console.log('gitlab-mcp-v${version}'); +else if (args[1] === 'download') { + if (args[2] !== 'gitlab-mcp-v${version}') throw new Error('Wrong release'); + const destination = args[args.indexOf('--dir') + 1]; + fs.writeFileSync(path.join(destination, 'gitlab-mcp-${version}.mcpb'), 'fixture bundle'); +} else throw new Error('Unexpected GitHub command'); +`, + { mode: 0o755 }, + ); + try { + const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', step.run], { + cwd: root, + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH}`, + GITHUB_OUTPUT: output, + }, + encoding: 'utf8', + timeout: 10000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(readFileSync(output, 'utf8'), `version=${version}\n`); + assert.equal( + readFileSync(join(root, 'docs/public/downloads', `gitlab-mcp-${version}.mcpb`), 'utf8'), + 'fixture bundle', + ); + assert.ok(result.stdout.includes(`MCPB bundle included: ${version}`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts index c7fdb790..c4d1d674 100644 --- a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -1,6 +1,4 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse } from 'yaml'; @@ -11,52 +9,3 @@ test('docs rebuild after release publication completes', () => { ); expect(workflow.on.workflow_run.workflows).toContain('Release'); }); - -// Execute the production workflow step offline: component tags must produce usable asset versions. -test.each(['13.1.0', '13.2.0'])( - 'docs download resolves release %s from its component tag', - (version) => { - const workflow = parse( - readFileSync(join(__dirname, '../../../../../.github/workflows/docs.yml'), 'utf8'), - ); - const step = workflow.jobs.build.steps.find( - (candidate: { id?: string }) => candidate.id === 'mcpb', - ); - const root = mkdtempSync(join(tmpdir(), 'gitlab-docs-release-')); - const bin = join(root, 'bin'); - const output = join(root, 'output'); - mkdirSync(bin); - writeFileSync( - join(bin, 'gh'), - `#!${process.execPath} -const fs = require('node:fs'), path = require('node:path'); -const args = process.argv.slice(2); -if (args[0] !== 'release') throw new Error('Unexpected GitHub request'); -if (args[1] === 'view') console.log('gitlab-mcp-v${version}'); -else if (args[1] === 'download') { - if (args[2] !== 'gitlab-mcp-v${version}') throw new Error('Wrong release'); - const destination = args[args.indexOf('--dir') + 1]; - fs.writeFileSync(path.join(destination, 'gitlab-mcp-${version}.mcpb'), 'fixture bundle'); -} else throw new Error('Unexpected GitHub command'); -`, - { mode: 0o755 }, - ); - try { - const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', step.run], { - cwd: root, - env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_OUTPUT: output }, - encoding: 'utf8', - timeout: 10000, - }); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); - expect(readFileSync(output, 'utf8')).toBe(`version=${version}\n`); - expect( - readFileSync(join(root, 'docs/public/downloads', `gitlab-mcp-${version}.mcpb`), 'utf8'), - ).toBe('fixture bundle'); - expect(result.stdout).toContain(`MCPB bundle included: ${version}`); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, -); From 25f1dd1ca291dfb6cb3af97770387635184c6f79 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:54:52 +0200 Subject: [PATCH 13/22] test(roo): compare native path separators Join the complete expected Darwin and Linux paths with the host path implementation. Preserve the raw Windows environment-variable expansion and strict file-preservation assertions. --- packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts index 55262d35..dbb69ef9 100644 --- a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts @@ -56,7 +56,8 @@ describe('Roo Code global installation', () => { ])('writes the active default VS Code extension configuration on %s', (os, storage) => { Object.defineProperty(process, 'platform', { value: os }); process.env.APPDATA = 'C:/Profiles/test/AppData/Roaming'; - const expected = `${storage}/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; + const suffix = 'rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + const expected = os === 'win32' ? `${storage}/${suffix}` : join(storage, suffix); const existing = { mcpServers: { other: { command: 'other-server' } } }; mockedFs.existsSync.mockImplementation((path) => String(path) === expected); mockedFs.readFileSync.mockReturnValue(JSON.stringify(existing)); From df2a2f7d7cb22f68fcdb543ba6e2687d23aa9ad2 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 12:56:42 +0200 Subject: [PATCH 14/22] ci(docs): quote the release output path --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 42e2de4e..fa711356 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,7 +59,7 @@ jobs: LATEST_TAG=$(gh release view --json tagName --jq '.tagName' 2>/dev/null || echo "") if [ -n "$LATEST_TAG" ]; then VERSION="${LATEST_TAG#gitlab-mcp-v}" - echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" gh release download "$LATEST_TAG" --pattern "*.mcpb" --dir docs/public/downloads/ || true MCPB_FILE="docs/public/downloads/gitlab-mcp-${VERSION}.mcpb" if [ -f "$MCPB_FILE" ]; then @@ -68,7 +68,7 @@ jobs: else # Fallback to package.json version if no release exists VERSION=$(jq -r '.version' package.json) - echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "MCPB bundle version from package.json: ${VERSION}" fi env: From 53bfea789a3f0376a8fbdead757894b33fe4a924 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:08:27 +0200 Subject: [PATCH 15/22] test: cover namespace credential boundaries --- .../services/NamespaceTierDetector.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts index 1c87f0b5..f7773d88 100644 --- a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts @@ -1,14 +1,17 @@ /** REST subscription observations, credential isolation and bounded cache lifecycle. */ import { + canResolveNamespaceTier, clearNamespaceTierCache, getNamespaceTier, getNamespaceTierCacheMetrics, } from '../../../src/services/NamespaceTierDetector'; import * as tokenContext from '../../../src/oauth/token-context'; +import { isOAuthEnabled } from '../../../src/oauth/config'; import * as fetchUtils from '../../../src/utils/fetch'; import { getGitLabBaseUrl } from '../../../src/utils/gitlab-base-url'; jest.mock('../../../src/oauth/token-context'); +jest.mock('../../../src/oauth/config', () => ({ isOAuthEnabled: jest.fn(() => false) })); jest.mock('../../../src/utils/fetch'); jest.mock('../../../src/utils/gitlab-base-url'); jest.mock('../../../src/logger', () => ({ @@ -23,6 +26,7 @@ const mockedEnhancedFetch = jest.mocked(fetchUtils.enhancedFetch); describe('NamespaceTierDetector', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(isOAuthEnabled).mockReturnValue(false); clearNamespaceTierCache(); jest.mocked(getGitLabBaseUrl).mockImplementation(() => mockedGetTokenContext()?.apiUrl ?? ''); jest.mocked(fetchUtils.getAuthHeaders).mockImplementation((): Record => { @@ -164,6 +168,32 @@ describe('NamespaceTierDetector', () => { expect(mockedEnhancedFetch).not.toHaveBeenCalled(); }); + // Known insufficient OAuth scopes must avoid optional billing requests entirely. + it('returns unknown when the credential cannot read API metadata', async () => { + mockedGetTokenContext.mockReturnValue({ ...context, gitlabScopes: ['read_user'] }); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); + + // OAuth mode must not borrow static credentials when no user token is selected. + it('returns unknown without an OAuth token context', async () => { + jest.mocked(isOAuthEnabled).mockReturnValue(true); + mockedGetTokenContext.mockReturnValue(undefined); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); + + // Billing observations belong only to the authenticated transport's selected instance. + it('resolves metadata only for the selected instance', () => { + expect(canResolveNamespaceTier(context.apiUrl)).toBe(true); + expect(canResolveNamespaceTier('https://other.example')).toBe(false); + jest.mocked(getGitLabBaseUrl).mockReturnValue(''); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); + // Neither an HTTP error nor malformed provider output constitutes subscription evidence. test.each([403, 404, 429, 500])('returns unknown for metadata HTTP %s', async (status) => { mockedEnhancedFetch.mockImplementation(async () => new Response('fixture error', { status })); From d83d0a990fa9957d31a570da7297b2d9c9b52112 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:48:47 +0200 Subject: [PATCH 16/22] fix(docs): refresh after asset publication Notify documentation after MCPB upload succeeds, independently of later publishing failures. Add a regression check for the publication event and its ordering. --- .github/workflows/docs.yml | 9 ++----- .github/workflows/release-please.yml | 7 ++++++ .../tests/unit/release/docs-download.test.ts | 24 ++++++++++++++++--- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fa711356..938562ad 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,8 @@ on: - 'packages/gitlab-mcp/package.json' - 'yarn.lock' - '.github/workflows/docs.yml' - workflow_run: - workflows: ["Release"] - types: [completed] - branches: [main] + repository_dispatch: + types: [mcpb-published] workflow_dispatch: permissions: @@ -26,9 +24,6 @@ concurrency: jobs: build: runs-on: ubuntu-latest - if: > - github.event_name != 'workflow_run' || - github.event.workflow_run.conclusion == 'success' # docs/, vitepress, and the docs scripts live in the core package; run the # docs steps from there. (uses: steps below set explicit paths where needed.) defaults: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f9ec42e3..137870b8 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -242,6 +242,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # repository_dispatch can trigger downstream workflows using GITHUB_TOKEN. + # https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow + - name: Refresh documentation downloads + run: gh api --method POST "repos/${GITHUB_REPOSITORY}/dispatches" -f event_type=mcpb-published + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Codex marketplace to GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts index c4d1d674..7f60fcd6 100644 --- a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -2,10 +2,28 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse } from 'yaml'; -// Refresh downloads after the release workflow has finished uploading its assets. -test('docs rebuild after release publication completes', () => { +// An unrelated later publishing failure must not suppress already-uploaded downloads. +test('MCPB publication triggers docs before subsequent publishing steps', () => { const workflow = parse( readFileSync(join(__dirname, '../../../../../.github/workflows/docs.yml'), 'utf8'), ); - expect(workflow.on.workflow_run.workflows).toContain('Release'); + const release = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/release-please.yml'), 'utf8'), + ); + const steps = release.jobs['post-release'].steps; + const upload = steps.findIndex((step: { name: string }) => + step.name === 'Upload MCPB bundles to GitHub Release', + ); + const notification = steps.findIndex((step: { run?: string }) => + step.run?.includes('event_type=mcpb-published'), + ); + const docker = steps.findIndex((step: { name: string }) => step.name === 'Set up Docker Buildx'); + expect(upload).toBeGreaterThanOrEqual(0); + expect(notification).toBeGreaterThan(upload); + expect(notification).toBeLessThan(docker); + expect(steps[notification].if).toBeUndefined(); + expect(steps[notification]['continue-on-error']).toBeUndefined(); + expect(workflow.on.repository_dispatch.types).toEqual(['mcpb-published']); + expect(workflow.on.workflow_run).toBeUndefined(); + expect(workflow.jobs.build.if).toBeUndefined(); }); From 098328ce20238ba55adb50e1a23b41d777f953e8 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:49:04 +0200 Subject: [PATCH 17/22] docs: clarify full-suite prerequisites --- TESTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index 22e63c72..cc370db6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -73,11 +73,15 @@ suite and requires Bash. The required Quality Checks job executes it with: node --test tests/scripts/docs-download.test.mjs ``` -### Complete Unit and Integration Test Suite +### Complete Unit and Integration Test Suite (requires `.env.test`) ```bash yarn test:all ``` +Treat this as full-suite evidence only with the protected `.env.test` configuration +and designated test namespace available. Without that file, the test configuration +selects unit tests only, even when this command succeeds. + This command: - ✅ Uses all feature flags (WORKITEMS, MILESTONE, PIPELINE, GITLAB_WIKI) - ✅ Runs suites serially with one worker and lifecycle ordering; accumulated worker memory can be reclaimed between files From 54cc5842291f970cd41210a5fde4ed5c24a22281 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:49:29 +0200 Subject: [PATCH 18/22] docs(codex): explain Yarn preparation --- packages/gitlab-mcp/docs/guides/codex-qualification.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/gitlab-mcp/docs/guides/codex-qualification.md b/packages/gitlab-mcp/docs/guides/codex-qualification.md index ffc68431..ce12407d 100644 --- a/packages/gitlab-mcp/docs/guides/codex-qualification.md +++ b/packages/gitlab-mcp/docs/guides/codex-qualification.md @@ -47,6 +47,10 @@ CLI executable and `unzip` available on `PATH`. The `unzip` requirement also app when running qualification on Windows; it is separate from extracting the release ZIP through a desktop file manager. +Install [Corepack](https://yarnpkg.com/corepack), run `corepack enable`, and run +`yarn install --immutable` at the repository root first. This activates the Yarn +version declared by the repository. + From `packages/gitlab-mcp`: ```bash From a916b94b6992fdbcb2731879aab55c122ad88e82 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:52:40 +0200 Subject: [PATCH 19/22] fix(roo): honor VS Code application data Use VS Code application-data precedence in shared installer metadata. Carry regression coverage for detection and installation with the override. --- packages/gitlab-mcp/docs/clients/roo-code.md | 7 ++-- packages/gitlab-mcp/src/cli/install/types.ts | 32 ++++++++++----- .../tests/unit/cli/install/roo-code.test.ts | 41 ++++++++++++++++++- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/packages/gitlab-mcp/docs/clients/roo-code.md b/packages/gitlab-mcp/docs/clients/roo-code.md index 4cc999cf..ef64c511 100644 --- a/packages/gitlab-mcp/docs/clients/roo-code.md +++ b/packages/gitlab-mcp/docs/clients/roo-code.md @@ -22,14 +22,15 @@ configuration for the same server name. For the standard VS Code profile, automatic setup writes the extension's global `settings/mcp_settings.json`: -On Linux, the standard profile is under `XDG_CONFIG_HOME/Code` when the variable -is nonempty, or `~/.config/Code` otherwise. +When `VSCODE_APPDATA` is nonempty, its `Code` directory takes precedence on all +platforms. Otherwise, Linux uses `XDG_CONFIG_HOME/Code` when the variable is +nonempty, or `~/.config/Code`. ```bash npx @structured-world/gitlab-mcp install --roo-code ``` -With a custom VS Code profile, another editor or Roo's custom storage path, open +With portable VS Code, a custom profile or `--user-data-dir`, another editor, or Roo's custom storage path, open **Edit Global MCP** to find and edit the active configuration. The installer does not migrate the unused home-level `~/.roo/mcp.json` file. diff --git a/packages/gitlab-mcp/src/cli/install/types.ts b/packages/gitlab-mcp/src/cli/install/types.ts index aad705b0..2c40ec1f 100644 --- a/packages/gitlab-mcp/src/cli/install/types.ts +++ b/packages/gitlab-mcp/src/cli/install/types.ts @@ -121,6 +121,21 @@ export interface ClientMetadata { /** * Client metadata registry */ +const ROO_SETTINGS_SUFFIX = + 'Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + +function rooConfigPath(platform: 'darwin' | 'win32' | 'linux'): string { + // Match VS Code's application-data precedence for the default profile. + // https://github.com/microsoft/vscode/blob/main/src/vs/platform/environment/node/userDataPath.ts + const appData = process.env.VSCODE_APPDATA; + if (appData) return `${appData}/${ROO_SETTINGS_SUFFIX}`; + if (platform === 'darwin') return `~/Library/Application Support/${ROO_SETTINGS_SUFFIX}`; + if (platform === 'win32') return `%APPDATA%/${ROO_SETTINGS_SUFFIX}`; + const configHome = process.env.XDG_CONFIG_HOME; + if (configHome) return `${configHome}/${ROO_SETTINGS_SUFFIX}`; + return `~/.config/${ROO_SETTINGS_SUFFIX}`; +} + export const CLIENT_METADATA: Record = { 'claude-desktop': { name: 'Claude Desktop', @@ -190,17 +205,14 @@ export const CLIENT_METADATA: Record = { 'roo-code': { name: 'Roo Code', configPaths: { - darwin: - '~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', - win32: - '%APPDATA%/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json', + get darwin(): string { + return rooConfigPath('darwin'); + }, + get win32(): string { + return rooConfigPath('win32'); + }, get linux(): string { - // VS Code uses XDG_CONFIG_HOME, with ~/.config as its standard Linux default. - // https://github.com/microsoft/vscode/blob/main/src/vs/platform/environment/node/userDataPath.ts - const root = process.env.XDG_CONFIG_HOME; - if (root) - return `${root}/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; - return '~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + return rooConfigPath('linux'); }, }, supportsCliInstall: false, diff --git a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts index dbb69ef9..af3a0044 100644 --- a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts @@ -1,7 +1,8 @@ import * as fs from 'fs'; import { homedir } from 'os'; -import { join } from 'path'; +import { dirname, join, normalize } from 'path'; import { installRooCode } from '../../../../src/cli/install/installers'; +import { detectClient } from '../../../../src/cli/install/detector'; jest.mock('fs'); jest.mock('../../../../src/cli/install/backup', () => ({ @@ -14,9 +15,11 @@ describe('Roo Code global installation', () => { const platform = process.platform; const appData = process.env.APPDATA; const configHome = process.env.XDG_CONFIG_HOME; + const vscodeAppData = process.env.VSCODE_APPDATA; beforeEach(() => { delete process.env.XDG_CONFIG_HOME; + delete process.env.VSCODE_APPDATA; }); afterEach(() => { @@ -25,6 +28,8 @@ describe('Roo Code global installation', () => { else process.env.APPDATA = appData; if (configHome === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = configHome; + if (vscodeAppData === undefined) delete process.env.VSCODE_APPDATA; + else process.env.VSCODE_APPDATA = vscodeAppData; jest.clearAllMocks(); }); @@ -48,6 +53,40 @@ describe('Roo Code global installation', () => { expect(written.mcpServers.gitlab).toEqual({ command: 'node', args: ['server.js'] }); }); + // VS Code's application-data override wins over platform defaults for detection and writes. + test.each(['linux', 'darwin', 'win32'])( + 'uses VSCODE_APPDATA for detection and installation on %s', + (os) => { + Object.defineProperty(process, 'platform', { value: os }); + process.env.XDG_CONFIG_HOME = join(homedir(), 'unused-config'); + process.env.VSCODE_APPDATA = join(homedir(), 'custom-vscode-data'); + const expected = `${process.env.VSCODE_APPDATA}/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; + mockedFs.existsSync.mockImplementation( + (path) => + normalize(String(path)) === normalize(expected) || + normalize(String(path)) === dirname(normalize(expected)), + ); + mockedFs.readFileSync.mockReturnValue( + JSON.stringify({ mcpServers: { other: { command: 'other-server' } } }), + ); + + expect(detectClient('roo-code')).toMatchObject({ + detected: true, + configExists: true, + configPath: expected, + }); + expect(installRooCode({ command: 'node', args: ['server.js'], env: {} })).toMatchObject({ + success: true, + configPath: expected, + }); + const written = JSON.parse(mockedFs.writeFileSync.mock.calls[0][1] as string); + expect(written.mcpServers).toEqual({ + other: { command: 'other-server' }, + gitlab: { command: 'node', args: ['server.js'] }, + }); + }, + ); + // Prevent successful installs into an unused home-level .roo file on any supported OS. test.each([ ['darwin', join(homedir(), 'Library/Application Support/Code/User/globalStorage')], From b7f332543e8f4a1cb2c75ee632bddabc74445714 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 13:54:24 +0200 Subject: [PATCH 20/22] refactor: validate namespace REST metadata Parse project, group and namespace response fields with Zod while preserving unknown subscriptions for invalid or unavailable metadata. Cover malformed bodies and credential boundaries. --- .../src/services/NamespaceTierDetector.ts | 23 ++++---- .../services/NamespaceTierDetector.test.ts | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts index 2bc480e8..93c17635 100644 --- a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts +++ b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts @@ -3,6 +3,7 @@ * These disposable facts guide discovery, not GitLab authorization. */ import { createHash } from 'node:crypto'; +import { z } from 'zod'; import type { NamespaceTierInfo } from '../oauth/types'; import type { GitLabTier } from './GitLabVersionDetector'; import { getTokenContext } from '../oauth/token-context'; @@ -17,6 +18,9 @@ import { isOAuthEnabled } from '../oauth/config'; const CACHE_TTL_MS = 5 * 60 * 1000; const CACHE_CAPACITY = 1024; const namespaceTierCache = new Map>(); +const ProjectMetadataSchema = z.object({ path_with_namespace: z.string() }); +const GroupMetadataSchema = z.object({ full_path: z.string() }); +const NamespaceMetadataSchema = z.object({ plan: z.string().nullish() }); interface Identity { baseUrl: string; @@ -133,12 +137,6 @@ async function readMetadata( return { status: response.status, value: await readBoundedJson(response, limit) }; } -function stringField(value: unknown, field: string): string | null { - if (value === null || typeof value !== 'object' || !(field in value)) return null; - const result = (value as Record)[field]; - return typeof result === 'string' ? result : null; -} - /** GitLab namespaces inherit the subscription of their root namespace. */ function rootOf(path: string): string | null { const canonical = canonicalProjectPath(path); @@ -157,7 +155,12 @@ async function numericTargetPath( const collection = kind === 'project' ? 'projects' : 'groups'; const { status, value } = await readMetadata(collection, id, caller, 1024 * 1024); if (kind === 'either' && status === 404) return numericTargetPath(id, 'project', caller); - return stringField(value, kind === 'project' ? 'path_with_namespace' : 'full_path'); + if (kind === 'project') { + const parsed = ProjectMetadataSchema.safeParse(value); + return parsed.success ? parsed.data.path_with_namespace : null; + } + const parsed = GroupMetadataSchema.safeParse(value); + return parsed.success ? parsed.data.full_path : null; } /** @@ -187,8 +190,10 @@ export async function getNamespaceTier( if (namespace === null) observation = unknown(); else { const { value } = await readMetadata('namespaces', namespace, caller, 64 * 1024); - const plan = stringField(value, 'plan'); - const tier = plan === null ? undefined : PLAN_TIERS.get(plan.toLowerCase()); + const parsed = NamespaceMetadataSchema.safeParse(value); + const plan = parsed.success ? parsed.data.plan : null; + const tier = + plan === null || plan === undefined ? undefined : PLAN_TIERS.get(plan.toLowerCase()); observation = { tier: tier ?? null, cachedAt: new Date() }; } } catch { diff --git a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts index f7773d88..8e724169 100644 --- a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts @@ -77,6 +77,58 @@ describe('NamespaceTierDetector', () => { expect((await getNamespaceTier('team')).tier).toBe(expected); }); + // Only a string plan in an object establishes subscription evidence. + test.each([null, [], 'premium', { plan: 42 }, { plan: ['premium'] }])( + 'keeps malformed namespace metadata unknown: %j', + async (value) => { + mockedEnhancedFetch.mockResolvedValue(new Response(JSON.stringify(value))); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }, + ); + + // Static-token mode has no OAuth user or session but still uses its own credential identity. + it('supports authenticated metadata without an OAuth context', async () => { + mockedGetTokenContext.mockReturnValue(undefined); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'fixture-static' }); + expect((await getNamespaceTier('team')).tier).toBe('premium'); + expect((await getNamespaceTier('team/subgroup')).tier).toBe('premium'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + }); + + // A response without a body still carries no billing evidence and needs no stream cancellation. + it('handles an unavailable metadata response without a body', async () => { + mockedEnhancedFetch.mockResolvedValue(new Response(null, { status: 403 })); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }); + + // Namespace limits are checked before allocating a provider response. + it('does not request an overlong root namespace', async () => { + expect((await getNamespaceTier('a'.repeat(256))).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); + + // Malformed numeric metadata must not be interpreted as another target's namespace. + test.each(['project', 'group'] as const)('rejects malformed %s metadata', async (kind) => { + mockedEnhancedFetch.mockResolvedValue( + new Response(JSON.stringify({ path_with_namespace: 7, full_path: [] })), + ); + expect((await getNamespaceTier('7', kind)).tier).toBeNull(); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + }); + + // A valid group match must not fall through to a project with the same numeric ID. + it('resolves an ambiguous target as a group when the group exists', async () => { + mockedEnhancedFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ full_path: 'team/subgroup' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('7', 'either')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://gitlab.com/api/v4/groups/7?with_projects=false', + 'https://gitlab.com/api/v4/namespaces/team', + ]); + }); + // Descendants share the root subscription and do not issue duplicate metadata requests. it('uses the root REST entity and shares its observation with descendants', async () => { const first = await getNamespaceTier('team/subgroup/project'); From c29096c51f845148edadd3b2abd703c7b1e10646 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 14:00:40 +0200 Subject: [PATCH 21/22] test: cover unknown namespace capabilities Exercise permission errors, widget validation, account discovery and cache refresh across known and unknown subscriptions. Cover numeric targets and denied-action dispatch. --- .../tests/unit/RegistryManager.test.ts | 13 ++++--- .../namespace-capabilities.test.ts | 38 +++++++++++++++++++ .../unit/entities/context/whoami.test.ts | 14 +++++++ .../unit/entities/workitems/registry.test.ts | 35 +++++++++++++++++ .../gitlab-mcp/tests/unit/handlers.test.ts | 20 ++++++++++ .../tests/unit/release/docs-download.test.ts | 4 +- .../unit/services/ConnectionManager.test.ts | 19 ++++++++++ .../tests/unit/utils/error-handler.test.ts | 25 +++++++++++- 8 files changed, 159 insertions(+), 9 deletions(-) diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index fe5146f6..e73daa91 100644 --- a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts @@ -434,10 +434,10 @@ describe('RegistryManager', () => { const url = 'https://gitlab.com'; try { registryManager.refreshCache(url); - const observe = (tier: 'free' | 'ultimate') => + const observe = (tier: 'free' | 'ultimate' | null) => runWithNamespaceTier(url, tier, async () => { await Promise.resolve(); - const available = tier === 'ultimate'; + const available = tier !== 'free'; expect(registryManager.getAvailableToolNames(url).includes('paid_namespace_tool')).toBe( available, ); @@ -458,11 +458,12 @@ describe('RegistryManager', () => { registryManager.executeTool('paid_namespace_tool', {}, url), ).rejects.toThrow('not found'); }); - await Promise.all([observe('free'), observe('ultimate')]); - registryManager.refreshCache(url); - await Promise.all([observe('ultimate'), observe('free')]); + await Promise.all([observe(null), observe('free'), observe('ultimate')]); + // Refreshing one known view must also rebuild the existing unknown and sibling views. + runWithNamespaceTier(url, 'free', () => registryManager.refreshCache(url)); + await Promise.all([observe('ultimate'), observe(null), observe('free')]); expect(registryManager.hasToolHandler('paid_namespace_tool', url)).toBe(true); - expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledTimes(4); } finally { core.delete('paid_namespace_tool'); } diff --git a/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts index 5a7c225a..7795f17c 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts @@ -36,6 +36,44 @@ describe('namespace capabilities', () => { expect(namespaceTargetsForScope(undefined)).toEqual([]); }); + // Configured plural scopes and path arguments must retain all relevant roots and kinds. + it('extracts plural scopes and nonnumeric argument paths', () => { + expect(namespaceTargetsForScope({ projects: ['team/a'], namespace: 'group' })).toEqual([ + { path: 'team/a', kind: 'project' }, + { path: 'group', kind: 'group' }, + ]); + expect(namespaceTargetsForArgs({ project_id: 'team/a', group_id: 'group' })).toEqual([ + { path: 'team/a', kind: 'project' }, + { path: 'group', kind: 'group' }, + ]); + }); + + // Numeric projects and groups must not deduplicate just because their IDs coincide. + it('retains numeric target kinds and agrees on equal subscriptions', async () => { + const targets = [ + { path: '7', kind: 'project' as const }, + { path: '7', kind: 'group' as const }, + { path: 'team', kind: 'group' as const }, + ]; + expect(await withNamespaceCapabilities(url, targets, false, observed)).toBe('free'); + expect(lookup.mock.calls.map(([path, kind]) => ({ path, kind }))).toEqual(targets); + }); + + // Union selection cannot downgrade when a lower subscription appears later in the input. + it('retains the higher tier when a lower tier follows it', async () => { + expect( + await withNamespaceCapabilities( + url, + [ + { path: 'paid/a', kind: 'project' }, + { path: 'free/b', kind: 'project' }, + ], + true, + observed, + ), + ).toBe('ultimate'); + }); + // Free-only tools do not pay for optional billing observations. it('requests observations only for declared paid capabilities', () => { expect(needsNamespaceTier(undefined)).toBe(false); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts index a5333f6e..ad44b2ef 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts @@ -142,6 +142,20 @@ describe('whoami handler', () => { }); describe('action: whoami', () => { + // A connected SaaS instance can have a known version but no observable namespace plan. + it('reports an unknown tier without discarding the detected version', async () => { + mockConnectionManager.getInstanceInfo.mockReturnValue({ + version: '17.5.2', + tier: null, + features: {}, + detectedAt: new Date(), + }); + const result = (await handleManageContext({ action: 'whoami' })) as WhoamiResult; + expect(result.server.version).toBe('17.5.2'); + expect(result.server.tier).toBe('unknown'); + expect(result.userLookup).toBe('ok'); + }); + it('should return complete WhoamiResult structure', async () => { const result = (await handleManageContext({ action: 'whoami' })) as WhoamiResult; diff --git a/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts index 7850ec8a..36cc5c6f 100644 --- a/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts @@ -4,6 +4,7 @@ import { getWorkitemsToolDefinitions, getFilteredWorkitemsTools, } from '../../../../src/entities/workitems/registry'; +import { StructuredToolError } from '../../../../src/utils/error-handler'; // Create mock client const mockClient = { @@ -271,6 +272,40 @@ describe('Workitems Registry - CQRS Tools', () => { mockValidateWidgetParams.mockClear(); }); + // Version restrictions must remain useful when GitLab does not disclose the subscription. + test.each(['create', 'update'])( + 'omits an unknown tier from %s widget errors', + async (action) => { + mockValidateWidgetParams.mockReturnValueOnce({ + parameter: 'weight', + widget: 'WEIGHT', + requiredVersion: '19.0', + detectedVersion: '16.0.0', + requiredTier: 'premium', + currentTier: null, + }); + const input = + action === 'create' + ? { action, namespace: 'test-group', workItemType: 'EPIC', title: 'Test', weight: 1 } + : { action, id: '123', weight: 1 }; + const failure = await workitemsToolRegistry + .get('manage_work_item')! + .handler(input) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(StructuredToolError); + expect((failure as StructuredToolError).structuredError).toMatchObject({ + error_code: 'VERSION_RESTRICTED', + tool: 'manage_work_item', + action, + }); + expect((failure as StructuredToolError).structuredError).toHaveProperty( + 'current_tier', + undefined, + ); + expect(mockClient.request).not.toHaveBeenCalled(); + }, + ); + // Helper function to create complete mock work items const createMockWorkItem = (overrides: Record = {}) => ({ id: 'gid://gitlab/WorkItem/1', diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 10353b52..658825fa 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -427,6 +427,26 @@ describe('handlers', () => { } }); + // Version-denied actions are rejected by dispatch without spending a billing lookup. + it('leaves unavailable actions to the existing action gate', async () => { + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + unavailableActions: new Set(['list']), + }); + mockRegistryManager.executeTool.mockRejectedValueOnce(new Error('Action unavailable')); + const result = await runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { + params: { name: 'browse_iterations', arguments: { action: 'list', group_id: 'team' } }, + }, + { sessionId: 'namespace-chat' }, + ), + ); + expect(result.isError).toBe(true); + expect(mockRegistryManager.executeTool).toHaveBeenCalledTimes(1); + expect(lookup).not.toHaveBeenCalled(); + }); + // Invalid paid-tool input must fail validation before an optional metadata read. it('validates required arguments before namespace resolution', async () => { mockRegistryManager.getTool.mockReturnValue({ diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts index 7f60fcd6..14b30ef7 100644 --- a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -11,8 +11,8 @@ test('MCPB publication triggers docs before subsequent publishing steps', () => readFileSync(join(__dirname, '../../../../../.github/workflows/release-please.yml'), 'utf8'), ); const steps = release.jobs['post-release'].steps; - const upload = steps.findIndex((step: { name: string }) => - step.name === 'Upload MCPB bundles to GitHub Release', + const upload = steps.findIndex( + (step: { name: string }) => step.name === 'Upload MCPB bundles to GitHub Release', ); const notification = steps.findIndex((step: { run?: string }) => step.run?.includes('event_type=mcpb-published'), diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts index ae972a48..c10bd4a3 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts @@ -111,6 +111,25 @@ describe('ConnectionManager Unit', () => { expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); }); + // Knowing a subscription does not establish which version-dependent widgets exist. + it('keeps feature availability unknown until the SaaS version is known', async () => { + const { runWithNamespaceTier } = await import('../../../src/services/namespace-tier-context'); + const manager = ConnectionManager.getInstance(); + injectInstanceState(manager, 'https://gitlab.com', { + instanceInfo: { + version: 'unknown', + tier: 'free', + features: GitLabVersionDetector.determineFeatures('19.5.0', 'free'), + detectedAt: new Date(), + }, + }); + runWithNamespaceTier('https://gitlab.com', 'ultimate', () => { + expect(manager.getInstanceInfo('https://gitlab.com').features).toEqual({}); + expect(manager.getTier('https://gitlab.com')).toBe('ultimate'); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBeNull(); + }); + }); + describe('singleton pattern', () => { it('should return the same instance on multiple calls', () => { const instance1 = ConnectionManager.getInstance(); diff --git a/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts b/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts index 14b4b21f..35af6d6a 100644 --- a/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts @@ -20,7 +20,7 @@ import { // Mock functions that we'll configure per-test const mockGetTier = jest.fn(() => 'free'); -const mockIsFeatureAvailable = jest.fn((feature: string) => { +const mockIsFeatureAvailable = jest.fn((feature: string): boolean | null => { // Default: Free tier - Premium/Ultimate features unavailable const premiumFeatures = ['epics', 'iterations', 'serviceDesk', 'weight']; const ultimateFeatures = ['okrs', 'requirements', 'securityDashboard']; @@ -58,6 +58,29 @@ describe('Error Handler', () => { }); describe('handleGitLabError', () => { + // Unknown billing metadata must not turn a GitLab permission denial into an upgrade claim. + test.each([ + { tool: 'browse_iterations', args: {} }, + { tool: 'browse_webhooks', args: { scope: 'group' } }, + { tool: 'browse_work_items', args: { types: ['EPIC'] } }, + { tool: 'browse_work_items', args: { types: ['OBJECTIVE'] } }, + { tool: 'manage_work_item', args: { types: ['REQUIREMENT'] } }, + ])( + 'preserves permission denial with unknown capabilities for $tool $args', + ({ tool, args }) => { + mockGetTier.mockReturnValue('unknown'); + mockIsFeatureAvailable.mockReturnValue(null); + const result = handleGitLabError( + { status: 403, message: '403 Forbidden' }, + tool, + 'list', + args, + ); + expect(result.error_code).toBe('PERMISSION_DENIED'); + expect(result).not.toHaveProperty('tier_required'); + }, + ); + describe('403 Forbidden - Tier Restricted', () => { it('should create tier-restricted error for iterations on Free tier', () => { const error: GitLabApiErrorResponse = { From 9f11a2ccbb7bc3510f8f54a0e63d27b171e233f5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 2 Nov 2026 14:02:33 +0200 Subject: [PATCH 22/22] fix(release): require matching MCPB assets Do not signal successful publication when an expected bundle is missing. Keep the upload contract in the publication regression test. --- .github/workflows/release-please.yml | 1 + packages/gitlab-mcp/tests/unit/release/docs-download.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 137870b8..b560bc33 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -236,6 +236,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.release-please.outputs.release-tag }} + fail_on_unmatched_files: true files: | packages/gitlab-mcp/gitlab-mcp-[0-9]*.mcpb packages/gitlab-mcp/gitlab-mcp-db-*.mcpb diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts index 14b30ef7..005cad44 100644 --- a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -19,6 +19,7 @@ test('MCPB publication triggers docs before subsequent publishing steps', () => ); const docker = steps.findIndex((step: { name: string }) => step.name === 'Set up Docker Buildx'); expect(upload).toBeGreaterThanOrEqual(0); + expect(steps[upload].with.fail_on_unmatched_files).toBe(true); expect(notification).toBeGreaterThan(upload); expect(notification).toBeLessThan(docker); expect(steps[notification].if).toBeUndefined();